Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03ee337e28 | |||
| 48ffd7e49b | |||
| 8df94bca26 | |||
| d2aced396f | |||
| 4e93722ba5 | |||
| 33a5996bee | |||
| 90475d4130 | |||
| 8e984e609b | |||
| b48502c34a | |||
| 978992057a | |||
| ed0fb8dd64 | |||
| 828bc89890 | |||
| b8bf4f88cc | |||
| f74587376e | |||
| a135196cb5 | |||
| cfdcb573de | |||
| f17215f236 | |||
| 5e60614588 | |||
| e385049652 | |||
| ef295e0994 | |||
| c3382a422d | |||
| 5d47e6d291 | |||
| 957ba6c72c | |||
| 4b551ccc31 | |||
| 9dc1faee6f | |||
| b998833371 | |||
| 13cd68707e | |||
| f355b9471a | |||
| a64af5f9cf | |||
| 7182391241 | |||
| 28ea9abccd | |||
| 9f41400bd8 | |||
| 78c5754655 | |||
| 5145835c8a | |||
| 6b15e7ff02 | |||
| a65be48d36 | |||
| ac91f6e4c8 | |||
| 09b1ee882d | |||
| acf867a375 | |||
| dd2635a44d | |||
| 29c19c6372 | |||
| 01d6d7439f | |||
| 860395cb14 | |||
| b1a539235b | |||
| 403c169ddd | |||
| 8d79cd76f6 | |||
| 392a4d4766 | |||
| 55fba75fda | |||
| 36157bd53b | |||
| 52706cab7d | |||
| ae97f061e8 | |||
| e34d197dd0 | |||
| fdf41e05fc | |||
| ae74035ad5 | |||
| ac11bb56db | |||
| 5081546cdf | |||
| 9da8a9494d | |||
| 4373ed684a | |||
| def0e0d0f9 | |||
| 36e8658dad | |||
| 6c1b77d7d2 | |||
| 4640526e4b | |||
| c7692ab9b6 | |||
| 36fbbb9386 | |||
| ef11309399 | |||
| 6f2dd0fc01 | |||
| 623f0164c8 | |||
| 88b47099b1 | |||
| 659d2a0840 | |||
| 68950dba10 | |||
| 3e215ae8e8 | |||
| e7b8c672e0 | |||
| 2462850e2e | |||
| abb61f65cd | |||
| da32d59161 | |||
| 40b1c4f2ab | |||
| 55de887f87 | |||
| ffe3cb6ecd | |||
| 69c67a1636 | |||
| f181009fa5 | |||
| b6551da147 | |||
| 2afb2971da | |||
| 14ffbb8657 | |||
| fbe5ef873b | |||
| d6ee1dde74 | |||
| 288b40ad12 | |||
| 3a3296c340 | |||
| e664a3797e | |||
| 36418673db | |||
| 98e4a4a340 | |||
| bfbbb66747 | |||
| 1933b58f84 | |||
| 05dc80bb6d | |||
| 4d63bff214 | |||
| c5474f1ff6 | |||
| 7ad785fed9 | |||
| e66f242c2c | |||
| 6026c2227a | |||
| 1eed9b00de | |||
| 1709638bfd | |||
| 37b5d14d7e | |||
| 2fabdb79df | |||
| 00554224a1 | |||
| 88b2182789 | |||
| 989d60643b | |||
| 1772ced959 | |||
| c005009da2 | |||
| bfd18e05dd | |||
| 29d39ae7b0 | |||
| 047170d4be | |||
| fc96ee9eb8 | |||
| 0a584a13c7 | |||
| f78c72ad60 | |||
| 8cca4a83ca | |||
| bc49e081c6 | |||
| 9e26aabce6 | |||
| 3e1a5db1a6 | |||
| baa83aad8a | |||
| 302a82e887 | |||
| 63da413b3c | |||
| 02c493f7e0 | |||
| b939fc959d | |||
| 5464d90948 |
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
server/node_modules/
|
||||||
|
|
||||||
|
# Bỏ qua cấu hình hệ thống và Git
|
||||||
|
.git/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Bỏ qua các file log và build
|
||||||
|
*.log
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
node_modules
|
node_modules
|
||||||
|
server/dist
|
||||||
|
dist
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"continue.enableConsole": true
|
"continue.enableConsole": true,
|
||||||
|
"remote.autoForwardPortsFallback": 0
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Install openssl for Prisma
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
|
# Development stage
|
||||||
|
FROM base AS development
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["npm", "run", "start:dev"]
|
||||||
|
|
||||||
|
# Build stage for production
|
||||||
|
FROM base AS build
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
RUN npm prune --production
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY --from=build /usr/src/app/node_modules ./node_modules
|
||||||
|
COPY --from=build /usr/src/app/dist ./dist
|
||||||
|
COPY --from=build /usr/src/app/prisma ./prisma
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "dist/src/main.js"]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5';
|
||||||
|
const ownerUserId = '5b2053bb-f523-4a11-817c-f47fef7322bb'; // owner@travel.com
|
||||||
|
|
||||||
|
// Check if owner@travel.com is already a participant
|
||||||
|
const existing = await prisma.tourParticipant.findFirst({
|
||||||
|
where: { tourId, userId: ownerUserId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.tourParticipant.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { role: 'OWNER' }
|
||||||
|
});
|
||||||
|
console.log('Updated existing participant to OWNER');
|
||||||
|
} else {
|
||||||
|
// Demote current owner to MEMBER or just keep them
|
||||||
|
const result = await prisma.tourParticipant.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: ownerUserId,
|
||||||
|
role: 'OWNER'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('Created new OWNER participant:', result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tour createdById to ownerUserId
|
||||||
|
await prisma.tour.update({
|
||||||
|
where: { id: tourId },
|
||||||
|
data: { createdById: ownerUserId }
|
||||||
|
});
|
||||||
|
console.log('Updated tour creator to owner@travel.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
|
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
|
||||||
export declare class JwtAuthGuard extends JwtAuthGuard_base {
|
export declare class JwtAuthGuard extends JwtAuthGuard_base {
|
||||||
}
|
}
|
||||||
|
declare const JwtAuthGuardNoAnonymous_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
|
||||||
|
export declare class JwtAuthGuardNoAnonymous extends JwtAuthGuardNoAnonymous_base {
|
||||||
|
handleRequest(err: any, user: any, info: any): any;
|
||||||
|
}
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -6,13 +6,29 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|||||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.JwtAuthGuard = void 0;
|
exports.JwtAuthGuardNoAnonymous = exports.JwtAuthGuard = void 0;
|
||||||
const common_1 = require("@nestjs/common");
|
const common_1 = require("@nestjs/common");
|
||||||
const passport_1 = require("@nestjs/passport");
|
const passport_1 = require("@nestjs/passport");
|
||||||
|
const common_2 = require("@nestjs/common");
|
||||||
let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') {
|
let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') {
|
||||||
};
|
};
|
||||||
exports.JwtAuthGuard = JwtAuthGuard;
|
exports.JwtAuthGuard = JwtAuthGuard;
|
||||||
exports.JwtAuthGuard = JwtAuthGuard = __decorate([
|
exports.JwtAuthGuard = JwtAuthGuard = __decorate([
|
||||||
(0, common_1.Injectable)()
|
(0, common_1.Injectable)()
|
||||||
], JwtAuthGuard);
|
], JwtAuthGuard);
|
||||||
|
let JwtAuthGuardNoAnonymous = class JwtAuthGuardNoAnonymous extends (0, passport_1.AuthGuard)('jwt') {
|
||||||
|
handleRequest(err, user, info) {
|
||||||
|
if (err || !user) {
|
||||||
|
throw err || new common_2.UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
|
||||||
|
}
|
||||||
|
if (user.isAnonymous) {
|
||||||
|
throw new common_2.UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous;
|
||||||
|
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous = __decorate([
|
||||||
|
(0, common_1.Injectable)()
|
||||||
|
], JwtAuthGuardNoAnonymous);
|
||||||
//# sourceMappingURL=jwt-auth.guard.js.map
|
//# sourceMappingURL=jwt-auth.guard.js.map
|
||||||
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAGtC,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B"}
|
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAC7C,2CAAuD;AAGhD,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B;AAI9C,IAAM,uBAAuB,GAA7B,MAAM,uBAAwB,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;IAC3D,aAAa,CAAC,GAAQ,EAAE,IAAS,EAAE,IAAS;QAC1C,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YACjB,MAAM,GAAG,IAAI,IAAI,8BAAqB,CAAC,6CAA6C,CAAC,CAAC;QACxF,CAAC;QAGD,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YACrB,MAAM,IAAI,8BAAqB,CAAC,gGAAgG,CAAC,CAAC;QACpI,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAbY,0DAAuB;kCAAvB,uBAAuB;IADnC,IAAA,mBAAU,GAAE;GACA,uBAAuB,CAanC"}
|
||||||
@@ -5,8 +5,8 @@ export declare class JwtStrategy extends JwtStrategy_base {
|
|||||||
constructor(prisma: PrismaService);
|
constructor(prisma: PrismaService);
|
||||||
validate(payload: any): Promise<{
|
validate(payload: any): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string | null;
|
||||||
passwordHash: string;
|
passwordHash: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
address: string | null;
|
address: string | null;
|
||||||
@@ -14,6 +14,7 @@ export declare class JwtStrategy extends JwtStrategy_base {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isBlocked: boolean;
|
isBlocked: boolean;
|
||||||
|
isAnonymous: boolean;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AApBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAoBvB"}
|
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAID,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAtBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAsBvB"}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
export declare class CompressCacheInterceptor implements NestInterceptor {
|
||||||
|
private cacheManager;
|
||||||
|
private readonly httpAdapterHost;
|
||||||
|
constructor(cacheManager: Cache, httpAdapterHost: HttpAdapterHost);
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
|
};
|
||||||
|
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||||
|
return function (target, key) { decorator(target, key, paramIndex); }
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CompressCacheInterceptor = void 0;
|
||||||
|
const common_1 = require("@nestjs/common");
|
||||||
|
const rxjs_1 = require("rxjs");
|
||||||
|
const operators_1 = require("rxjs/operators");
|
||||||
|
const cache_manager_1 = require("@nestjs/cache-manager");
|
||||||
|
const zlib = __importStar(require("zlib"));
|
||||||
|
const util_1 = require("util");
|
||||||
|
const core_1 = require("@nestjs/core");
|
||||||
|
const gzip = (0, util_1.promisify)(zlib.gzip);
|
||||||
|
const gunzip = (0, util_1.promisify)(zlib.gunzip);
|
||||||
|
const COMPRESSION_THRESHOLD = 100;
|
||||||
|
let CompressCacheInterceptor = class CompressCacheInterceptor {
|
||||||
|
constructor(cacheManager, httpAdapterHost) {
|
||||||
|
this.cacheManager = cacheManager;
|
||||||
|
this.httpAdapterHost = httpAdapterHost;
|
||||||
|
}
|
||||||
|
async intercept(context, next) {
|
||||||
|
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||||
|
const request = context.getArgByIndex(0);
|
||||||
|
const response = context.getArgByIndex(1);
|
||||||
|
if (httpAdapter.getRequestMethod(request) !== 'GET') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
const cacheKey = httpAdapter.getRequestUrl(request);
|
||||||
|
let cachedData = await this.cacheManager.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
try {
|
||||||
|
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
|
||||||
|
const decompressed = await gunzip(cachedData);
|
||||||
|
const jsonString = decompressed.toString('utf8');
|
||||||
|
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
|
||||||
|
return (0, rxjs_1.of)(JSON.parse(jsonString));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const jsonString = cachedData.toString();
|
||||||
|
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
|
||||||
|
return (0, rxjs_1.of)(JSON.parse(jsonString));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
|
||||||
|
await this.cacheManager.del(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
|
||||||
|
return next.handle().pipe((0, operators_1.tap)(async (data) => {
|
||||||
|
if (!data)
|
||||||
|
return;
|
||||||
|
const jsonString = JSON.stringify(data);
|
||||||
|
const ttl = 60000;
|
||||||
|
if (jsonString.length > COMPRESSION_THRESHOLD) {
|
||||||
|
try {
|
||||||
|
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
|
||||||
|
await this.cacheManager.set(cacheKey, compressed, ttl);
|
||||||
|
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
|
||||||
|
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.CompressCacheInterceptor = CompressCacheInterceptor;
|
||||||
|
exports.CompressCacheInterceptor = CompressCacheInterceptor = __decorate([
|
||||||
|
(0, common_1.Injectable)(),
|
||||||
|
__param(0, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||||
|
__metadata("design:paramtypes", [Object, core_1.HttpAdapterHost])
|
||||||
|
], CompressCacheInterceptor);
|
||||||
|
//# sourceMappingURL=compress-cache.interceptor.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"compress-cache.interceptor.js","sourceRoot":"","sources":["../../../src/common/compress-cache.interceptor.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAAoG;AACpG,+BAAsC;AACtC,8CAAqC;AACrC,yDAAsD;AAEtD,2CAA6B;AAC7B,+BAAiC;AACjC,uCAA+C;AAG/C,MAAM,IAAI,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAClC,MAAM,MAAM,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAItC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAG3B,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IACnC,YACiC,YAAmB,EACjC,eAAgC;QADlB,iBAAY,GAAZ,YAAY,CAAO;QACjC,oBAAe,GAAf,eAAe,CAAiB;IAChD,CAAC;IAEJ,KAAK,CAAC,SAAS,CAAC,OAAyB,EAAE,IAAiB;QAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC;QACrD,MAAM,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QAG1C,IAAI,WAAW,CAAC,gBAAgB,CAAC,OAAO,CAAC,KAAK,KAAK,EAAE,CAAC;YACpD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC;QACvB,CAAC;QAED,MAAM,QAAQ,GAAG,WAAW,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QACpD,IAAI,UAAU,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAS,QAAQ,CAAC,CAAC;QAE/D,IAAI,UAAU,EAAE,CAAC;YACf,IAAI,CAAC;gBAEH,IAAI,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC7G,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;oBAC9C,MAAM,UAAU,GAAG,YAAY,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;oBAEjD,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,kBAAkB,CAAC,CAAC;oBAC/D,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;qBAAM,CAAC;oBAEN,MAAM,UAAU,GAAG,UAAU,CAAC,QAAQ,EAAE,CAAC;oBACzC,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;oBACjE,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;YACH,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,OAAO,CAAC,KAAK,CAAC,8CAA8C,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;gBAE5E,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACxC,CAAC;QACH,CAAC;QAID,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;QAEnD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CACvB,IAAA,eAAG,EAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YACjB,IAAI,CAAC,IAAI;gBAAE,OAAO;YAElB,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,KAAK,CAAC;YAElB,IAAI,UAAU,CAAC,MAAM,GAAG,qBAAqB,EAAE,CAAC;gBAC9C,IAAI,CAAC;oBACH,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC;oBAC/D,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;oBACvD,OAAO,CAAC,GAAG,CAAC,kCAAkC,QAAQ,KAAK,UAAU,CAAC,MAAM,OAAO,UAAU,CAAC,MAAM,SAAS,CAAC,CAAC;gBAEjH,CAAC;gBAAC,OAAO,CAAC,EAAE,CAAC;oBACX,OAAO,CAAC,KAAK,CAAC,mCAAmC,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;oBAEjE,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;gBACzD,CAAC;YACH,CAAC;iBAAM,CAAC;gBAEN,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;YACzD,CAAC;QACH,CAAC,CAAC,CACH,CAAC;IACJ,CAAC;CACF,CAAA;AAxEY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,mBAAU,GAAE;IAGR,WAAA,IAAA,eAAM,EAAC,6BAAa,CAAC,CAAA;6CACY,sBAAe;GAHxC,wBAAwB,CAwEpC"}
|
||||||
@@ -1 +1,35 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
import { OnGatewayConnection } from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ParticipantRole } from '@prisma/client';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
export declare class CommentGateway implements OnGatewayConnection {
|
||||||
|
server: Server;
|
||||||
|
handleConnection(client: Socket): void;
|
||||||
|
handleJoinTour(client: Socket, tourId: string): void;
|
||||||
|
handleJoinPhoto(client: Socket, photoId: string): void;
|
||||||
|
notifyNewComment(tourId: string, data: any): void;
|
||||||
|
notifyNewPhotoComment(photoId: string, data: any): void;
|
||||||
|
handleJoinUser(client: Socket, userId: string): void;
|
||||||
|
notifyNewMessage(receiverId: string, data: any): void;
|
||||||
|
notifyConnectionAccepted(requesterId: string, data: any): void;
|
||||||
|
notifyJoinRequestAccepted(userId: string, data: any): void;
|
||||||
|
}
|
||||||
|
export declare const ROLES_KEY = "roles";
|
||||||
|
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
|
||||||
|
export declare class TourRoleGuard implements CanActivate {
|
||||||
|
private reflector;
|
||||||
|
private prisma;
|
||||||
|
private cacheManager;
|
||||||
|
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||||
|
}
|
||||||
|
export declare class EmailService {
|
||||||
|
private transporter;
|
||||||
|
constructor();
|
||||||
|
sendOTP(email: string, otp: string): Promise<any>;
|
||||||
|
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
|
||||||
|
> backend@0.0.1 start:dev
|
||||||
|
> nest start --watch
|
||||||
|
|
||||||
|
[2J[3J[H[[90m10:46:05 PM[0m] Starting compilation in watch mode...
|
||||||
|
|
||||||
|
[[90m10:46:08 PM[0m] Found 0 errors. Watching for file changes.
|
||||||
|
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[NestFactory] [39m[32mStarting Nest application...[39m
|
||||||
|
--- [PRISMA CHECK] ---
|
||||||
|
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
|
||||||
|
----------------------
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigHostModule dependencies initialized[39m[38;5;3m +40ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mJwtModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mCacheModule dependencies initialized[39m[38;5;3m +15ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mAppModule dependencies initialized[39m[38;5;3m +2ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[WebSocketsController] [39m[32mCommentGateway subscribed to the "joinTour" message[39m[38;5;3m +11ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[WebSocketsController] [39m[32mCommentGateway subscribed to the "joinPhoto" message[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAppController {/api/v1}:[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1, GET} route[39m[38;5;3m +2ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAuthController {/api/v1/auth}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/convert-guest, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/create-guest, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/status, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/login, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/signup/request, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/signup/verify, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPublicTourController {/api/v1/tours}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id/public, GET} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mTourController {/api/v1/tours}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/locations, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/start-point, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/end-point, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/legs/batch, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/legs, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, PATCH} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/explore, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/members, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests, GET} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/members/:userId, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/photos, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mUserController {/api/v1/users}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/me/photos, GET} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/:id, PATCH} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/block/:id, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mRoutingController {/api/v1/routing}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/routing/optimize/:legId, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mLegController {/api/v1/legs}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/legs/:id, PATCH} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/legs/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mLocationController {/api/v1/locations}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:id, PATCH} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:id, DELETE} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mCommentController {/api/v1/locations}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:locationId/comments, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:locationId/comments, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPhotoController {/api/v1/photos}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/photos/upload-anonymous, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/photos/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPublicPhotoController {/api/v1/public-photos}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos/:photoId/comments, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos/:photoId/comments, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAdminOtpController {/api/v1/admin/otp}:[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/admin/otp/send, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/admin/otp/verify, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[NestApplication] [39m[32mNest application successfully started[39m[38;5;3m +16ms[39m
|
||||||
|
🚀 Server is running on: http://localhost:3001
|
||||||
|
[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.
|
||||||
|
[WS] Client connected: sIV1IoQvXb-xaa9iAAAC
|
||||||
|
[WS] Client sIV1IoQvXb-xaa9iAAAC joined room: photo_557913e6-fcb4-4dae-8cff-a6ff52da3386
|
||||||
@@ -2,34 +2,49 @@
|
|||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma",
|
"db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma",
|
||||||
"db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma",
|
"db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma",
|
||||||
"db:seed": "node --loader ts-node/esm seed.ts"
|
"db:seed": "dotenv -e ../.env -- tsx seed.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.23",
|
"@nestjs/cli": "^11.0.23",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/pg": "^8.11.6",
|
"@types/pg": "^8.11.6",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"prisma": "^5.16.2",
|
"prisma": "^5.16.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.5.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nestjs/cache-manager": "^3.1.3",
|
||||||
"@nestjs/common": "^11.1.27",
|
"@nestjs/common": "^11.1.27",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.1.27",
|
"@nestjs/core": "^11.1.27",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.27",
|
"@nestjs/platform-express": "^11.1.27",
|
||||||
"@prisma/client": "^5.16.2",
|
"@nestjs/platform-socket.io": "^11.1.27",
|
||||||
|
"@nestjs/websockets": "^11.1.27",
|
||||||
"@prisma/adapter-pg": "^5.16.2",
|
"@prisma/adapter-pg": "^5.16.2",
|
||||||
|
"@prisma/client": "^5.16.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"cache-manager": "^7.2.8",
|
||||||
|
"cache-manager-redis-yet": "^5.1.5",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
|
"nodemailer": "^9.0.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
|
"redis": "^6.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2",
|
||||||
|
"sharp": "^0.35.1",
|
||||||
|
"socket.io": "^4.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Comment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"locationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Tour" ADD COLUMN "tags" TEXT[];
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Photo" DROP CONSTRAINT "Photo_tourId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Photo" ADD COLUMN "originalUrl" TEXT,
|
||||||
|
ALTER COLUMN "tourId" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "imageUrl" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ALTER COLUMN "email" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "passwordHash" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Comment" ADD COLUMN "photoId" TEXT,
|
||||||
|
ALTER COLUMN "locationId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_photoId_fkey" FOREIGN KEY ("photoId") REFERENCES "Photo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Location" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TourParticipant" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The primary key for the `TourParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||||
|
- A unique constraint covering the columns `[tourId,userId]` on the table `TourParticipant` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- The required column `id` was added to the `TourParticipant` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TourParticipant" DROP CONSTRAINT "TourParticipant_pkey",
|
||||||
|
ADD COLUMN "displayName" TEXT,
|
||||||
|
ADD COLUMN "id" TEXT;
|
||||||
|
|
||||||
|
UPDATE "TourParticipant" SET "id" = md5(random()::text);
|
||||||
|
|
||||||
|
ALTER TABLE "TourParticipant" ALTER COLUMN "id" SET NOT NULL,
|
||||||
|
ALTER COLUMN "userId" DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TourParticipant_tourId_userId_key" ON "TourParticipant"("tourId", "userId");
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TourInvitation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiredAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TourInvitation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TourInvitation_token_key" ON "TourInvitation"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TourInvitation_tourId_email_key" ON "TourInvitation"("tourId", "email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourInvitation" ADD CONSTRAINT "TourInvitation_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ConnectionType" AS ENUM ('FRIEND', 'FAMILY');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserConnection" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"requesterId" TEXT NOT NULL,
|
||||||
|
"receiverId" TEXT NOT NULL,
|
||||||
|
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"type" "ConnectionType" NOT NULL DEFAULT 'FRIEND',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "UserConnection_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DirectMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"senderId" TEXT NOT NULL,
|
||||||
|
"receiverId" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DirectMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UserConnection_requesterId_idx" ON "UserConnection"("requesterId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UserConnection_receiverId_idx" ON "UserConnection"("receiverId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UserConnection_requesterId_receiverId_key" ON "UserConnection"("requesterId", "receiverId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DirectMessage_senderId_idx" ON "DirectMessage"("senderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DirectMessage_receiverId_idx" ON "DirectMessage"("receiverId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DirectMessage" ADD COLUMN "attachmentUrl" TEXT,
|
||||||
|
ADD COLUMN "latitude" DOUBLE PRECISION,
|
||||||
|
ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TourMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"senderId" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"attachmentUrl" TEXT,
|
||||||
|
"latitude" DOUBLE PRECISION,
|
||||||
|
"longitude" DOUBLE PRECISION,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TourMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TourMessage_tourId_idx" ON "TourMessage"("tourId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TourMessage_senderId_idx" ON "TourMessage"("senderId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WordFilter" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"word" TEXT NOT NULL,
|
||||||
|
"replacement" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WordFilter_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ModerationSetting" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"blockNsfw" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"blurFaces" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "ModerationSetting_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TourRating" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"targetUserId" TEXT NOT NULL,
|
||||||
|
"raterUserId" TEXT NOT NULL,
|
||||||
|
"honesty" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"transparency" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"enthusiasm" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"cheerfulness" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"seriousness" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"planning" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"survival" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"averageScore" DOUBLE PRECISION NOT NULL DEFAULT 5.0,
|
||||||
|
"comment" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TourRating_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TourShare" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TourShare_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WordFilter_word_key" ON "WordFilter"("word");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TourRating_tourId_targetUserId_raterUserId_key" ON "TourRating"("tourId", "targetUserId", "raterUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TourShare_tourId_key" ON "TourShare"("tourId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TourShare_token_key" ON "TourShare"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_raterUserId_fkey" FOREIGN KEY ("raterUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourShare" ADD CONSTRAINT "TourShare_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BusinessReport" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"address" TEXT,
|
||||||
|
"latitude" DOUBLE PRECISION,
|
||||||
|
"longitude" DOUBLE PRECISION,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"isBlacklisted" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "BusinessReport_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ModerationSetting" ADD COLUMN "trashRetentionDays" INTEGER NOT NULL DEFAULT 30;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Photo" ADD COLUMN "deletedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Tour" ADD COLUMN "deletedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TourNote" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "TourNote_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RecommendedLocation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"address" TEXT,
|
||||||
|
"latitude" DOUBLE PRECISION,
|
||||||
|
"longitude" DOUBLE PRECISION,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"stars" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"isApproved" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "RecommendedLocation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Photo" ADD COLUMN "flaggedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "flaggedReason" TEXT,
|
||||||
|
ADD COLUMN "isFlagged" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -57,8 +57,8 @@ enum PrivacyLevel {
|
|||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String? @unique
|
||||||
passwordHash String
|
passwordHash String?
|
||||||
name String?
|
name String?
|
||||||
phone String?
|
phone String?
|
||||||
address String?
|
address String?
|
||||||
@@ -66,6 +66,7 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
isBlocked Boolean @default(false)
|
isBlocked Boolean @default(false)
|
||||||
|
isAnonymous Boolean @default(false)
|
||||||
|
|
||||||
createdTours Tour[] @relation("TourCreator")
|
createdTours Tour[] @relation("TourCreator")
|
||||||
tourParticipations TourParticipant[]
|
tourParticipations TourParticipant[]
|
||||||
@@ -73,6 +74,15 @@ model User {
|
|||||||
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
|
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
|
||||||
uploadedPhotos Photo[]
|
uploadedPhotos Photo[]
|
||||||
paidExpenses Expense[] @relation("ExpensePaidBy")
|
paidExpenses Expense[] @relation("ExpensePaidBy")
|
||||||
|
comments Comment[]
|
||||||
|
sentConnections UserConnection[] @relation("ConnectionRequester")
|
||||||
|
receivedConnections UserConnection[] @relation("ConnectionReceiver")
|
||||||
|
sentMessages DirectMessage[] @relation("MessageSender")
|
||||||
|
receivedMessages DirectMessage[] @relation("MessageReceiver")
|
||||||
|
tourMessages TourMessage[]
|
||||||
|
receivedRatings TourRating[] @relation("RatedUser")
|
||||||
|
sentRatings TourRating[] @relation("RatingUser")
|
||||||
|
tourNotes TourNote[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Tour {
|
model Tour {
|
||||||
@@ -88,6 +98,7 @@ model Tour {
|
|||||||
childCount Int @default(0)
|
childCount Int @default(0)
|
||||||
childDiscount Int @default(30)
|
childDiscount Int @default(30)
|
||||||
|
|
||||||
|
tags String[]
|
||||||
createdById String
|
createdById String
|
||||||
creator User @relation("TourCreator", fields: [createdById], references: [id])
|
creator User @relation("TourCreator", fields: [createdById], references: [id])
|
||||||
|
|
||||||
@@ -95,6 +106,13 @@ model Tour {
|
|||||||
joinRequests JoinRequest[]
|
joinRequests JoinRequest[]
|
||||||
legs Leg[]
|
legs Leg[]
|
||||||
photos Photo[]
|
photos Photo[]
|
||||||
|
invitations TourInvitation[]
|
||||||
|
tourMessages TourMessage[]
|
||||||
|
ratings TourRating[]
|
||||||
|
share TourShare?
|
||||||
|
notes TourNote[]
|
||||||
|
isDeleted Boolean @default(false)
|
||||||
|
deletedAt DateTime?
|
||||||
}
|
}
|
||||||
|
|
||||||
model JoinRequest {
|
model JoinRequest {
|
||||||
@@ -114,14 +132,18 @@ model JoinRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TourParticipant {
|
model TourParticipant {
|
||||||
|
id String @id @default(uuid())
|
||||||
tourId String
|
tourId String
|
||||||
userId String
|
userId String?
|
||||||
role ParticipantRole @default(MEMBER)
|
role ParticipantRole @default(MEMBER)
|
||||||
|
displayName String?
|
||||||
|
adultCount Int @default(1)
|
||||||
|
childCount Int @default(0)
|
||||||
|
|
||||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([tourId, userId])
|
@@unique([tourId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Leg {
|
model Leg {
|
||||||
@@ -157,6 +179,9 @@ model Location {
|
|||||||
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
||||||
expenses Expense[]
|
expenses Expense[]
|
||||||
photos Photo[]
|
photos Photo[]
|
||||||
|
comments Comment[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
model Expense {
|
model Expense {
|
||||||
@@ -176,15 +201,202 @@ model Expense {
|
|||||||
|
|
||||||
model Photo {
|
model Photo {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tourId String
|
tourId String?
|
||||||
locationId String?
|
locationId String?
|
||||||
uploaderId String
|
uploaderId String
|
||||||
imageUrl String
|
imageUrl String?
|
||||||
|
originalUrl String?
|
||||||
capturedAt DateTime @default(now())
|
capturedAt DateTime @default(now())
|
||||||
metadata Json?
|
metadata Json?
|
||||||
privacy PrivacyLevel @default(TOUR_ONLY)
|
privacy PrivacyLevel @default(TOUR_ONLY)
|
||||||
|
isFlagged Boolean @default(false)
|
||||||
|
flaggedReason String?
|
||||||
|
flaggedAt DateTime?
|
||||||
|
|
||||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
|
||||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||||
uploader User @relation(fields: [uploaderId], references: [id])
|
uploader User @relation(fields: [uploaderId], references: [id])
|
||||||
|
comments Comment[]
|
||||||
|
isDeleted Boolean @default(false)
|
||||||
|
deletedAt DateTime?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Comment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
locationId String?
|
||||||
|
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||||
|
photoId String?
|
||||||
|
photo Photo? @relation(fields: [photoId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model TourInvitation {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tourId String
|
||||||
|
email String
|
||||||
|
role ParticipantRole @default(MEMBER)
|
||||||
|
token String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiredAt DateTime
|
||||||
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([tourId, email])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionStatus {
|
||||||
|
PENDING
|
||||||
|
ACCEPTED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionType {
|
||||||
|
FRIEND
|
||||||
|
FAMILY
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserConnection {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
requesterId String
|
||||||
|
receiverId String
|
||||||
|
status ConnectionStatus @default(PENDING)
|
||||||
|
type ConnectionType @default(FRIEND)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
requester User @relation("ConnectionRequester", fields: [requesterId], references: [id], onDelete: Cascade)
|
||||||
|
receiver User @relation("ConnectionReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([requesterId, receiverId])
|
||||||
|
@@index([requesterId])
|
||||||
|
@@index([receiverId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DirectMessage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
senderId String
|
||||||
|
receiverId String
|
||||||
|
content String @db.Text
|
||||||
|
attachmentUrl String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade)
|
||||||
|
receiver User @relation("MessageReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([senderId])
|
||||||
|
@@index([receiverId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TourMessage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tourId String
|
||||||
|
senderId String
|
||||||
|
content String @db.Text
|
||||||
|
attachmentUrl String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
|
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tourId])
|
||||||
|
@@index([senderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WordFilter {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
word String @unique
|
||||||
|
replacement String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model ModerationSetting {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
blockNsfw Boolean @default(false)
|
||||||
|
blurFaces Boolean @default(false)
|
||||||
|
trashRetentionDays Int @default(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
model TourRating {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tourId String
|
||||||
|
targetUserId String
|
||||||
|
raterUserId String
|
||||||
|
honesty Int @default(5)
|
||||||
|
transparency Int @default(5)
|
||||||
|
enthusiasm Int @default(5)
|
||||||
|
cheerfulness Int @default(5)
|
||||||
|
seriousness Int @default(5)
|
||||||
|
planning Int @default(5)
|
||||||
|
survival Int @default(5)
|
||||||
|
averageScore Float @default(5.0)
|
||||||
|
comment String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
|
targetUser User @relation("RatedUser", fields: [targetUserId], references: [id], onDelete: Cascade)
|
||||||
|
raterUser User @relation("RatingUser", fields: [raterUserId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([tourId, targetUserId, raterUserId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TourShare {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tourId String @unique
|
||||||
|
token String @unique @default(uuid())
|
||||||
|
isEnabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model BusinessReport {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type String // e.g. "USER", "RESTAURANT", "HOTEL", "HOMESTAY"
|
||||||
|
name String
|
||||||
|
phone String?
|
||||||
|
email String?
|
||||||
|
address String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
reason String @db.Text
|
||||||
|
isBlacklisted Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model TourNote {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tourId String
|
||||||
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
title String
|
||||||
|
content String @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
isDeleted Boolean @default(false)
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model RecommendedLocation {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type String // e.g. "RESTAURANT", "HOTEL", "HOMESTAY"
|
||||||
|
name String
|
||||||
|
phone String?
|
||||||
|
email String?
|
||||||
|
address String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
description String @db.Text
|
||||||
|
stars Int @default(5)
|
||||||
|
isApproved Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||||
|
dotenv.config({ path: envPath });
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const users = await prisma.user.findMany();
|
||||||
|
console.log('USERS:', users);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().finally(() => pool.end());
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import 'dotenv/config';
|
import * as dotenv from 'dotenv';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as path from 'path';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||||
|
dotenv.config({ path: envPath });
|
||||||
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
const adapter = new PrismaPg(pool);
|
const adapter = new PrismaPg(pool);
|
||||||
@@ -90,6 +94,25 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('--- Đang tạo bình luận mẫu... ---');
|
||||||
|
const dinhDocLap = await prisma.location.findFirst({ where: { name: 'Dinh Độc Lập' } });
|
||||||
|
if (dinhDocLap) {
|
||||||
|
await prisma.comment.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
content: 'Chỗ này rất đẹp, giàu giá trị lịch sử!',
|
||||||
|
locationId: dinhDocLap.id,
|
||||||
|
userId: owner.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Nên đi vào buổi sáng cho mát mẻ mọi người nhé.',
|
||||||
|
locationId: dinhDocLap.id,
|
||||||
|
userId: photoMember.id,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log('--- Seed dữ liệu hoàn tất! ---');
|
console.log('--- Seed dữ liệu hoàn tất! ---');
|
||||||
console.log(`Email đăng nhập Owner: ${owner.email}`);
|
console.log(`Email đăng nhập Owner: ${owner.email}`);
|
||||||
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
|
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
|
|
||||||
|
// Guard that rejects anonymous/guest users - used for dashboard and sensitive endpoints
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuardNoAnonymous extends AuthGuard('jwt') {
|
||||||
|
handleRequest(err: any, user: any, info: any) {
|
||||||
|
if (err || !user) {
|
||||||
|
throw err || new UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject anonymous/temporary users
|
||||||
|
if (user.isAnonymous) {
|
||||||
|
throw new UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
|
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: We allow anonymous users to pass JWT validation
|
||||||
|
// Individual endpoints decide whether to accept anonymous users based on their guard
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Inject } from '@nestjs/common';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
import * as zlib from 'zlib';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
|
||||||
|
// Promisify các hàm nén/giải nén
|
||||||
|
const gzip = promisify(zlib.gzip);
|
||||||
|
const gunzip = promisify(zlib.gunzip);
|
||||||
|
|
||||||
|
// Ngưỡng nén: Chỉ nén nếu chuỗi JSON lớn hơn ngưỡng này (bytes)
|
||||||
|
// Nén dữ liệu quá nhỏ có thể làm tăng kích thước do overhead của header nén
|
||||||
|
const COMPRESSION_THRESHOLD = 100;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CompressCacheInterceptor implements NestInterceptor {
|
||||||
|
constructor(
|
||||||
|
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||||
|
private readonly httpAdapterHost: HttpAdapterHost, // Để truy cập request/response
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||||
|
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||||
|
const request = context.getArgByIndex(0);
|
||||||
|
const response = context.getArgByIndex(1);
|
||||||
|
|
||||||
|
// Chỉ áp dụng cho các request GET
|
||||||
|
if (httpAdapter.getRequestMethod(request) !== 'GET') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = httpAdapter.getRequestUrl(request);
|
||||||
|
let cachedData = await this.cacheManager.get<Buffer>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
try {
|
||||||
|
// Kiểm tra xem dữ liệu có phải là Buffer và có Gzip header (0x1f 0x8b) không
|
||||||
|
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
|
||||||
|
const decompressed = await gunzip(cachedData);
|
||||||
|
const jsonString = decompressed.toString('utf8');
|
||||||
|
|
||||||
|
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
|
||||||
|
return of(JSON.parse(jsonString));
|
||||||
|
} else {
|
||||||
|
// Dữ liệu không nén (lưu dưới dạng string hoặc buffer thường)
|
||||||
|
const jsonString = cachedData.toString();
|
||||||
|
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
|
||||||
|
return of(JSON.parse(jsonString));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
|
||||||
|
// Nếu giải nén lỗi, coi như cache miss và xóa cache bị lỗi
|
||||||
|
await this.cacheManager.del(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss hoặc giải nén lỗi, tiếp tục xử lý request
|
||||||
|
// Đặt header MISS ngay lập tức trước khi chạy logic Controller
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap(async (data) => { // Sử dụng tap để thực hiện side effect (lưu cache) mà không thay đổi dữ liệu gốc
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(data);
|
||||||
|
const ttl = 60000; // TTL mặc định 1 phút (có thể cấu hình từ CACHE_TTL.DEFAULT)
|
||||||
|
|
||||||
|
if (jsonString.length > COMPRESSION_THRESHOLD) {
|
||||||
|
try {
|
||||||
|
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
|
||||||
|
await this.cacheManager.set(cacheKey, compressed, ttl);
|
||||||
|
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
|
||||||
|
// Không setHeader ở đây vì response có thể đã gửi xong
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
|
||||||
|
// Nếu nén lỗi, lưu dữ liệu không nén làm fallback
|
||||||
|
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Dữ liệu quá nhỏ, lưu không nén
|
||||||
|
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
async function test() {
|
||||||
|
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'owner@travel.com', password: '123456' })
|
||||||
|
});
|
||||||
|
const loginData = await loginRes.json();
|
||||||
|
|
||||||
|
if (loginRes.ok) {
|
||||||
|
const token = loginData.access_token;
|
||||||
|
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Tour ID from seed
|
||||||
|
const res = await fetch(`http://localhost:3001/api/v1/tours/${tourId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ displayName: 'Offline Member X', role: 'MEMBER' })
|
||||||
|
});
|
||||||
|
console.log('Add status:', res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('Add response:', data);
|
||||||
|
} else {
|
||||||
|
console.error('Login failed:', loginData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const users = await prisma.user.findMany();
|
||||||
|
console.log('--- Users in DB ---');
|
||||||
|
console.log(users);
|
||||||
|
|
||||||
|
const participants = await prisma.tourParticipant.findMany();
|
||||||
|
console.log('--- Tour Participants in DB ---');
|
||||||
|
console.log(participants);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tours = await prisma.tour.findMany({
|
||||||
|
include: {
|
||||||
|
participants: {
|
||||||
|
include: {
|
||||||
|
user: { select: { email: true, name: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(tours, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Use existing tour ID from seed
|
||||||
|
|
||||||
|
console.log('Inserting first manual member...');
|
||||||
|
try {
|
||||||
|
const p1 = await prisma.tourParticipant.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
role: 'MEMBER',
|
||||||
|
displayName: 'Manual Member 1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('Inserted:', p1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to insert first:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Inserting second manual member...');
|
||||||
|
try {
|
||||||
|
const p2 = await prisma.tourParticipant.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
role: 'MEMBER',
|
||||||
|
displayName: 'Manual Member 2'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('Inserted:', p2);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to insert second:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
async function test() {
|
||||||
|
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'photomember@travel.com', password: '123456' })
|
||||||
|
});
|
||||||
|
const loginData = await loginRes.json();
|
||||||
|
|
||||||
|
if (loginRes.ok) {
|
||||||
|
const token = loginData.access_token;
|
||||||
|
const usersRes = await fetch('http://localhost:3001/api/v1/users?q=owner@travel.com', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
console.log('Users status:', usersRes.status);
|
||||||
|
const usersData = await usersRes.json();
|
||||||
|
console.log('Users data:', usersData);
|
||||||
|
} else {
|
||||||
|
console.error('Login failed:', loginData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
|
After Width: | Height: | Size: 868 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 844 KiB |
|
After Width: | Height: | Size: 408 KiB |
|
After Width: | Height: | Size: 422 KiB |
@@ -0,0 +1,73 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: yotrip-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: travel_db
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: yotrip-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
target: development
|
||||||
|
container_name: yotrip-backend
|
||||||
|
command: >
|
||||||
|
sh -c "npx prisma migrate dev --schema=prisma/schema.prisma && npm run start:dev"
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/usr/src/app
|
||||||
|
- /usr/src/app/node_modules
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
|
||||||
|
REDIS_URL: "redis://redis:6379"
|
||||||
|
PORT: 3001
|
||||||
|
ADMIN_SECRET_KEY: "yotrip_secret_admin_key"
|
||||||
|
JWT_SECRET: "super-secret"
|
||||||
|
FRONTEND_URL: "http://localhost:5173"
|
||||||
|
NODE_ENV: development
|
||||||
|
SMTP_HOST: "${SMTP_HOST}"
|
||||||
|
SMTP_PORT: "${SMTP_PORT}"
|
||||||
|
SMTP_SECURE: "${SMTP_SECURE}"
|
||||||
|
SMTP_USER: "${SMTP_USER}"
|
||||||
|
SMTP_PASS: "${SMTP_PASS}"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
target: development
|
||||||
|
container_name: yotrip-frontend
|
||||||
|
command: npm run dev -- --host 0.0.0.0
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/usr/src/app
|
||||||
|
- /usr/src/app/node_modules
|
||||||
|
environment:
|
||||||
|
VITE_API_URL: "http://localhost:3001"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: yotrip-db-prod
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: travel_db
|
||||||
|
volumes:
|
||||||
|
- pg_data_prod:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: yotrip-redis-prod
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
target: production
|
||||||
|
container_name: yotrip-backend-prod
|
||||||
|
restart: always
|
||||||
|
command: >
|
||||||
|
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- ./backend/uploads:/usr/src/app/uploads
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
|
||||||
|
REDIS_URL: "redis://redis:6379"
|
||||||
|
PORT: 3001
|
||||||
|
ADMIN_SECRET_KEY: "${ADMIN_SECRET_KEY}"
|
||||||
|
JWT_SECRET: "super-secret"
|
||||||
|
FRONTEND_URL: "${FRONTEND_URL}"
|
||||||
|
NODE_ENV: production
|
||||||
|
SMTP_HOST: "${SMTP_HOST}"
|
||||||
|
SMTP_PORT: "${SMTP_PORT}"
|
||||||
|
SMTP_SECURE: "${SMTP_SECURE}"
|
||||||
|
SMTP_USER: "${SMTP_USER}"
|
||||||
|
SMTP_PASS: "${SMTP_PASS}"
|
||||||
|
TZ: "Asia/Ho_Chi_Minh"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
target: production
|
||||||
|
args:
|
||||||
|
- VITE_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
|
container_name: yotrip-frontend-prod
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3002:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data_prod:
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: yotrip-db-prod
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: travel_db
|
||||||
|
volumes:
|
||||||
|
- pg_data_prod:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: yotrip-redis-prod
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
target: production
|
||||||
|
container_name: yotrip-backend-prod
|
||||||
|
restart: always
|
||||||
|
command: >
|
||||||
|
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- ./backend/uploads:/usr/src/app/uploads
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
|
||||||
|
REDIS_URL: "redis://redis:6379"
|
||||||
|
PORT: 3001
|
||||||
|
ADMIN_SECRET_KEY: "${ADMIN_SECRET_KEY}"
|
||||||
|
JWT_SECRET: "super-secret"
|
||||||
|
FRONTEND_URL: "${FRONTEND_URL}"
|
||||||
|
NODE_ENV: production
|
||||||
|
SMTP_HOST: "${SMTP_HOST}"
|
||||||
|
SMTP_PORT: "${SMTP_PORT}"
|
||||||
|
SMTP_SECURE: "${SMTP_SECURE}"
|
||||||
|
SMTP_USER: "${SMTP_USER}"
|
||||||
|
SMTP_PASS: "${SMTP_PASS}"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
target: production
|
||||||
|
args:
|
||||||
|
- VITE_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
|
container_name: yotrip-frontend-prod
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3002:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data_prod:
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Development stage
|
||||||
|
FROM base AS development
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 5173
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
|
||||||
|
# Build stage for production
|
||||||
|
FROM base AS build
|
||||||
|
ARG VITE_GOOGLE_CLIENT_ID
|
||||||
|
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage using Nginx
|
||||||
|
FROM nginx:1.25-alpine AS production
|
||||||
|
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 224 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<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" />
|
||||||
|
<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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,10 +2,27 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<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>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
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
|
||||||
|
location /api/v1/ {
|
||||||
|
proxy_pass http://backend:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy WebSocket connection
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://backend:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,18 +10,25 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
|
"jspdf-autotable": "^5.0.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.284.0",
|
"lucide-react": "^0.284.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-leaflet-cluster": "^2.1.0",
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
|
"react-quill": "^2.0.0",
|
||||||
|
"react-quill-new": "^3.8.3",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.1",
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
"@types/leaflet": "^1.9.12",
|
"@types/leaflet": "^1.9.12",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 224 KiB |
@@ -1,88 +1,348 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { LandingPage } from '@/pages/LandingPage';
|
import { LandingPage } from './pages/LandingPage';
|
||||||
import { TourDetailPage } from '@/pages/TourDetailPage';
|
import { ExploreMap } from './pages/ExploreMap';
|
||||||
import { ExploreMap } from '@/pages/ExploreMap';
|
import { TourDetailPage } from './pages/TourDetailPage';
|
||||||
import { SignupPage } from '@/pages/SignupPage';
|
import SignupPage from './pages/SignupPage';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { MyPhotosPage } from './pages/MyPhotosPage';
|
||||||
|
import { MyNotePage } from './pages/MyNotePage';
|
||||||
|
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';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const viewTourId = params.get('viewTour');
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const isJourneyShare = pathParts[1] === 'journey' && pathParts[2];
|
||||||
|
const journeyTokenVal = isJourneyShare ? pathParts[2] : null;
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
|
||||||
const [view, setView] = useState<View>('landing');
|
|
||||||
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
|
||||||
const [user, setUser] = useState<any>(null);
|
const [user, setUser] = useState<any>(null);
|
||||||
const [isUserLoaded, setIsUserLoaded] = useState(false); // Trạng thái để biết user đã được load từ localStorage chưa
|
const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal);
|
||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
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(() => {
|
useEffect(() => {
|
||||||
// Khôi phục phiên đăng nhập từ localStorage
|
const params = new URLSearchParams(window.location.search);
|
||||||
const savedUser = localStorage.getItem('user');
|
const viewTourId = params.get('viewTour');
|
||||||
if (savedUser) {
|
const isJoinTour = window.location.pathname === '/join-tour' || params.has('token');
|
||||||
const parsedUser = JSON.parse(savedUser);
|
const pathParts = window.location.pathname.split('/');
|
||||||
setUser(parsedUser);
|
const journeyToken = pathParts[1] === 'journey' && pathParts[2] ? pathParts[2] : null;
|
||||||
|
|
||||||
|
// Check if user just finished uploading from a public tour
|
||||||
|
const fromPublicUpload = localStorage.getItem('fromPublicUpload');
|
||||||
|
if (fromPublicUpload) {
|
||||||
|
localStorage.removeItem('fromPublicUpload');
|
||||||
|
setCurrentPage('landing');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setIsUserLoaded(true); // Đánh dấu user đã được load
|
|
||||||
|
|
||||||
// Kiểm tra xem hệ thống đã được cài đặt chưa
|
// Khôi phục thông tin đăng nhập nếu có
|
||||||
fetch(`/api/v1/auth/status`)
|
const token = localStorage.getItem('token');
|
||||||
.then(res => res.ok ? res.json() : Promise.reject())
|
const guestToken = localStorage.getItem('guest_token');
|
||||||
.then(data => setIsInitialSetup(!!data.isInitialSetup))
|
const storedUser = localStorage.getItem('user');
|
||||||
.catch(() => setIsInitialSetup(false));
|
const storedGuestUser = localStorage.getItem('guest_user');
|
||||||
}, []); // Chạy một lần khi component mount
|
|
||||||
|
|
||||||
// Effect để xử lý chuyển hướng nếu user đã đăng nhập và đang ở trang landing
|
let loggedInUser = null;
|
||||||
useEffect(() => {
|
if (token && storedUser) {
|
||||||
if (isUserLoaded && user && view === 'landing') {
|
try {
|
||||||
setView('explore');
|
loggedInUser = JSON.parse(storedUser);
|
||||||
|
setUser(loggedInUser);
|
||||||
|
} catch (e) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isUserLoaded, user, view]);
|
|
||||||
|
|
||||||
const handleLoginSuccess = (userData: any) => {
|
if (journeyToken) {
|
||||||
setUser(userData);
|
setShareJourneyToken(journeyToken);
|
||||||
|
setCurrentPage('shareJourney');
|
||||||
|
} else if (isJoinTour) {
|
||||||
|
setCurrentPage('joinTour');
|
||||||
|
} else if (viewTourId) {
|
||||||
|
setCurrentPage('tourDetail');
|
||||||
|
} else {
|
||||||
|
// CRITICAL: Check for guest token FIRST - guests should NEVER access dashboard
|
||||||
|
// regardless of whether they also have other tokens
|
||||||
|
if (guestToken) {
|
||||||
|
setCurrentPage('landing');
|
||||||
|
} else if (loggedInUser) {
|
||||||
|
// Real authenticated user (no guest token)
|
||||||
|
if (loggedInUser.isAdmin) {
|
||||||
|
setCurrentPage('admin');
|
||||||
|
} else {
|
||||||
|
setCurrentPage('dashboard');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCurrentPage('landing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoginSuccess = (loggedInUser: any) => {
|
||||||
|
setUser(loggedInUser);
|
||||||
|
|
||||||
|
// Nếu có pending token, ta vẫn giữ ở trang joinTour để nó tự động thực hiện join
|
||||||
|
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
|
||||||
|
if (pendingInviteToken) {
|
||||||
|
setCurrentPage('joinTour');
|
||||||
|
} else if (loggedInUser.isAdmin) {
|
||||||
|
// Nếu là admin, chuyển đến admin dashboard
|
||||||
|
setCurrentPage('admin');
|
||||||
|
} else {
|
||||||
|
// Only set to dashboard if this is a real user (has token), not a guest
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const guestToken = localStorage.getItem('guest_token');
|
||||||
|
if (token && !guestToken) {
|
||||||
|
setCurrentPage('dashboard');
|
||||||
|
} else {
|
||||||
|
setCurrentPage('landing');
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('user');
|
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setView('landing');
|
setCurrentPage('landing');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewTour = (tourId: string, fromPage?: 'explore' | 'dashboard') => {
|
||||||
|
setCurrentTourId(tourId);
|
||||||
|
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
|
||||||
|
setPreviousPage(fromPage || (currentPage === 'dashboard' ? 'dashboard' : 'explore'));
|
||||||
|
setCurrentPage('tourDetail');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackFromTourDetail = () => {
|
||||||
|
const wasPublicView = isPublicTourView;
|
||||||
|
|
||||||
|
setCurrentTourId(null);
|
||||||
|
setIsPublicTourView(false);
|
||||||
|
|
||||||
|
if (wasPublicView) {
|
||||||
|
setCurrentPage('landing');
|
||||||
|
} else if (user) {
|
||||||
|
setCurrentPage(previousPage);
|
||||||
|
} else {
|
||||||
|
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) {
|
||||||
|
setCurrentPage('joinTour');
|
||||||
|
} else {
|
||||||
|
setCurrentPage('landing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignupSuccess = () => {
|
||||||
|
// Sau khi đăng ký, ta có thể tự động đăng nhập hoặc quay lại landing/joinTour
|
||||||
|
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const storedUser = localStorage.getItem('user');
|
||||||
|
|
||||||
|
if (token && storedUser) {
|
||||||
|
try {
|
||||||
|
const loggedInUser = JSON.parse(storedUser);
|
||||||
|
setUser(loggedInUser);
|
||||||
|
if (pendingInviteToken) {
|
||||||
|
setCurrentPage('joinTour');
|
||||||
|
} else {
|
||||||
|
setCurrentPage('dashboard');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingInviteToken) {
|
||||||
|
setCurrentPage('joinTour');
|
||||||
|
} else {
|
||||||
|
setCurrentPage('landing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackFromExplore = () => {
|
||||||
|
// Only allow real users (with token, not guest_token)
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const guestToken = localStorage.getItem('guest_token');
|
||||||
|
const isRealUser = token && !guestToken;
|
||||||
|
|
||||||
|
if (user && isRealUser) {
|
||||||
|
setCurrentPage('dashboard');
|
||||||
|
} else {
|
||||||
|
setCurrentPage('landing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoToDashboard = () => {
|
||||||
|
// Only allow real users (with token, not guest_token)
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const guestToken = localStorage.getItem('guest_token');
|
||||||
|
const isRealUser = token && !guestToken;
|
||||||
|
|
||||||
|
if (user && isRealUser) {
|
||||||
|
setPreviousPage('explore');
|
||||||
|
setCurrentPage('dashboard');
|
||||||
|
} else {
|
||||||
|
setCurrentPage('landing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoToHome = () => {
|
||||||
|
window.history.pushState({}, '', '/');
|
||||||
|
setShareJourneyToken(null);
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const guestToken = localStorage.getItem('guest_token');
|
||||||
|
// Only real authenticated users can access dashboard, not guests
|
||||||
|
const isRealUser = token && !guestToken;
|
||||||
|
setCurrentPage(isRealUser ? 'dashboard' : 'landing');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<ConfirmProvider>
|
||||||
{view === 'landing' && (
|
<NotificationProvider>
|
||||||
<LandingPage
|
{(() => {
|
||||||
isInitialSetup={isInitialSetup}
|
if (currentPage === 'admin') {
|
||||||
onContinue={() => setView('explore')}
|
return (
|
||||||
onGoToSignup={() => setView('signup')}
|
<AdminDashboard
|
||||||
onGoToMap={() => setView('explore')}
|
|
||||||
onLoginSuccess={handleLoginSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'signup' && (
|
|
||||||
<SignupPage
|
|
||||||
onBack={() => setView('landing')}
|
|
||||||
onSuccess={() => setView('landing')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'explore' && (
|
|
||||||
<ExploreMap
|
|
||||||
onBack={() => setView('landing')}
|
|
||||||
onLogout={user ? handleLogout : undefined}
|
|
||||||
user={user}
|
user={user}
|
||||||
onViewTour={(id) => {
|
onNavigate={setCurrentPage}
|
||||||
fetchTour(id);
|
/>
|
||||||
setView('detail');
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'dashboard') {
|
||||||
|
// SECURITY: Prevent any guest from accessing dashboard
|
||||||
|
const guestToken = localStorage.getItem('guest_token');
|
||||||
|
if (guestToken) {
|
||||||
|
console.warn('[App] Guest user attempted to access dashboard - forcing redirect to landing');
|
||||||
|
setCurrentPage('landing');
|
||||||
|
return (
|
||||||
|
<LandingPage
|
||||||
|
onLoginSuccess={handleLoginSuccess}
|
||||||
|
onGoToSignup={() => setCurrentPage('signup')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MemberDashboard
|
||||||
|
user={user}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
onExploreTours={() => setCurrentPage('explore')}
|
||||||
|
onViewTour={handleViewTour}
|
||||||
|
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'tourDetail') {
|
||||||
|
return (
|
||||||
|
<TourDetailPage
|
||||||
|
tourId={currentTourId!}
|
||||||
|
onBack={handleBackFromTourDetail}
|
||||||
|
isPublicView={isPublicTourView}
|
||||||
|
onOpenNotes={() => setCurrentPage('notes')}
|
||||||
|
onOpenNavigationPage={handleOpenNavigationPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'notes') {
|
||||||
|
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
|
||||||
|
onBack={handleBackFromExplore}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
user={user}
|
||||||
|
onViewTour={handleViewTour}
|
||||||
|
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
||||||
|
onLoginSuccess={handleLoginSuccess}
|
||||||
|
onGoToDashboard={handleGoToDashboard}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'myPhotos') {
|
||||||
|
return (
|
||||||
|
<MyPhotosPage onBack={() => setCurrentPage('explore')} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'signup') {
|
||||||
|
return <SignupPage onBack={handleBackFromSignup} onSuccess={handleSignupSuccess} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'joinTour') {
|
||||||
|
return (
|
||||||
|
<JoinTourPage
|
||||||
|
onLoginSuccess={handleLoginSuccess}
|
||||||
|
onGoToSignup={() => setCurrentPage('signup')}
|
||||||
|
onViewTour={(tourId) => {
|
||||||
|
setCurrentTourId(tourId);
|
||||||
|
setIsPublicTourView(false);
|
||||||
|
setCurrentPage('tourDetail');
|
||||||
|
}}
|
||||||
|
onGoToHome={() => {
|
||||||
|
const loggedIn = !!localStorage.getItem('token');
|
||||||
|
setCurrentPage(loggedIn ? 'explore' : 'landing');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'detail' && (
|
|
||||||
<TourDetailPage onBack={() => setView('explore')} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'shareJourney') {
|
||||||
|
return (
|
||||||
|
<ShareJourneyPage
|
||||||
|
token={shareJourneyToken!}
|
||||||
|
onGoToHome={handleGoToHome}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
|
||||||
|
})()}
|
||||||
|
</NotificationProvider>
|
||||||
|
</ConfirmProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { useTourStore } from '@/store/useTourStore.js';
|
import { X, MapPin, Loader2, Map as MapIcon, Navigation, Search } from 'lucide-react';
|
||||||
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -43,13 +45,13 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
|
|||||||
{menuPos && (
|
{menuPos && (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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 }}
|
style={{ top: menuPos.y, left: menuPos.x }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { onPick(menuPos.latlng); setMenuPos(null); }}
|
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
|
<MapPin className="w-3 h-3" /> Thêm vào chặng hiện tại
|
||||||
</button>
|
</button>
|
||||||
@@ -59,7 +61,7 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => {
|
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isStartPoint = false, isEndPoint = false, isPublicView = false, onSuccess }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isStartPoint?: boolean, isEndPoint?: boolean, isPublicView?: boolean, onSuccess?: () => void }) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -77,9 +79,53 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
plannedEnd: ''
|
plannedEnd: ''
|
||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
const [hasNoResults, setHasNoResults] = useState(false);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const searchTimeout = useRef<any>(null);
|
||||||
|
|
||||||
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
||||||
const { legs, addLocation, updateLocation, mapCenter, currentTour } = useTourStore();
|
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
|
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,12 +142,14 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
type: editingLocation.type || 'VISIT',
|
type: editingLocation.type || 'VISIT',
|
||||||
legId: editingLocation.legId || '',
|
legId: editingLocation.legId || '',
|
||||||
note: editingLocation.note || '',
|
note: editingLocation.note || '',
|
||||||
expenseAmount: expense?.amount?.toString() || '',
|
expenseAmount: expense?.amount ? Number(expense.amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".") : '',
|
||||||
expenseCategory: expense?.category || 'OTHER',
|
expenseCategory: expense?.category || 'OTHER',
|
||||||
expenseDescription: expense?.description || '',
|
expenseDescription: expense?.description || '',
|
||||||
expenseNote: expense?.note || '',
|
expenseNote: expense?.note || '',
|
||||||
paidById: expense?.paidById || '',
|
paidById: expense?.paidById || '',
|
||||||
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '',
|
plannedStart: isStartPoint
|
||||||
|
? (editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : '')
|
||||||
|
: (editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : ''),
|
||||||
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
|
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -117,17 +165,105 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
}
|
}
|
||||||
}, [initialLegId, editingLocation, isOpen]);
|
}, [initialLegId, editingLocation, isOpen]);
|
||||||
|
|
||||||
|
// Memoize tọa độ để tránh việc bản đồ tự động reset tâm khi re-render (ví dụ khi gõ tìm kiếm)
|
||||||
|
const currentCoords = useMemo<[number, number]>(
|
||||||
|
() => [formData.latitude, formData.longitude],
|
||||||
|
[formData.latitude, formData.longitude]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng trước/hiện tại làm tham chiếu tiếp nối)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && !formData.name) {
|
if (isOpen && !editingLocation && formData.legId && !formData.name) {
|
||||||
|
const selectedLeg = legs.find(l => l.id === formData.legId);
|
||||||
|
|
||||||
|
if (selectedLeg) {
|
||||||
|
if (selectedLeg.locations && selectedLeg.locations.length > 0) {
|
||||||
|
// Di chuyển đến địa điểm cuối cùng của chặng hiện tại để người dùng thấy điểm nối tiếp
|
||||||
|
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
latitude: lastLoc.latitude,
|
||||||
|
longitude: lastLoc.longitude
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Chặng trống -> Lấy địa điểm cuối của chặng trước làm tọa độ tiếp nối
|
||||||
|
const currentLegIdx = legs.findIndex(l => l.id === formData.legId);
|
||||||
|
const prevLeg = currentLegIdx > 0 ? legs[currentLegIdx - 1] : null;
|
||||||
|
if (prevLeg && prevLeg.locations && prevLeg.locations.length > 0) {
|
||||||
|
const lastLoc = prevLeg.locations[prevLeg.locations.length - 1];
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
latitude: lastLoc.latitude,
|
||||||
|
longitude: lastLoc.longitude
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Nếu không có chặng trước hoặc chặng trước trống, mặc định dùng vị trí trung tâm hiện tại của tour
|
||||||
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
||||||
}
|
}
|
||||||
}, [isOpen, mapCenter]);
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
|
||||||
|
|
||||||
// 2. Thực hiện các tính toán và hàm xử lý
|
// 2. Thực hiện các tính toán và hàm xử lý
|
||||||
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
||||||
const targetLeg = legs.find(l => l.id === currentLegId);
|
const targetLeg = legs.find(l => l.id === currentLegId);
|
||||||
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
const titleText = isStartPoint ? 'Thiết lập Điểm xuất phát' :
|
||||||
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
isEndPoint ? 'Thiết lập Điểm kết thúc' :
|
||||||
|
editingLocation ? `Sửa địa điểm: ${editingLocation.name}` :
|
||||||
|
(initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
||||||
|
const buttonText = isStartPoint ? 'Xác nhận Điểm xuất phát' :
|
||||||
|
isEndPoint ? 'Xác nhận Điểm kết thúc' :
|
||||||
|
editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
||||||
|
|
||||||
|
const handleSearchLocation = (query: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, name: query }));
|
||||||
|
|
||||||
|
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||||
|
|
||||||
|
if (query.trim().length < 2) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setIsSearching(false);
|
||||||
|
setHasNoResults(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
setHasNoResults(false);
|
||||||
|
searchTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
// Loại bỏ countrycodes=vn để tìm kiếm rộng hơn, thêm namedetails=1 để lấy tên chính xác
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=15&addressdetails=1&namedetails=1&accept-language=vi`, {
|
||||||
|
headers: {
|
||||||
|
'Accept-Language': 'vi'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setSearchResults(data);
|
||||||
|
setHasNoResults(data.length === 0);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Lỗi tìm kiếm địa điểm:", e);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSearchResult = (result: any) => {
|
||||||
|
const lat = parseFloat(result.lat);
|
||||||
|
const lon = parseFloat(result.lon);
|
||||||
|
// Ưu tiên lấy tên từ namedetails nếu có, nếu không lấy phần đầu của display_name
|
||||||
|
const locationName = result.namedetails?.name || result.display_name.split(',')[0];
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
name: locationName,
|
||||||
|
address: result.display_name,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon
|
||||||
|
}));
|
||||||
|
setSearchResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePickLocation = async (latlng: L.LatLng) => {
|
const handlePickLocation = async (latlng: L.LatLng) => {
|
||||||
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
|
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
|
||||||
@@ -146,15 +282,83 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUseCurrentLocation = () => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
notify({ title: 'Thông báo', message: "Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị GPS.", type: 'info' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra môi trường Secure Context (HTTPS) - Bắt buộc cho Geolocation trên Mobile
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
alert("Tính năng định vị GPS yêu cầu kết nối bảo mật (HTTPS). Nếu bạn đang truy cập qua địa chỉ IP, vui lòng sử dụng HTTPS hoặc Localhost.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async (pos) => {
|
||||||
|
const latlng = L.latLng(pos.coords.latitude, pos.coords.longitude);
|
||||||
|
const now = new Date();
|
||||||
|
const formattedTime = format(now, "yyyy-MM-dd'T'HH:mm");
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
plannedStart: formattedTime
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Tự động thực hiện reverse geocoding để lấy tên địa điểm và địa chỉ
|
||||||
|
handlePickLocation(latlng);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
let errorMessage = "Không thể lấy vị trí: ";
|
||||||
|
switch(err.code) {
|
||||||
|
case err.PERMISSION_DENIED:
|
||||||
|
errorMessage += "Bạn đã từ chối quyền truy cập vị trí.";
|
||||||
|
break;
|
||||||
|
case err.POSITION_UNAVAILABLE:
|
||||||
|
errorMessage += "Thông tin vị trí không khả dụng.";
|
||||||
|
break;
|
||||||
|
case err.TIMEOUT:
|
||||||
|
errorMessage += "Hết thời gian chờ yêu cầu định vị.";
|
||||||
|
break;
|
||||||
|
default: errorMessage += err.message;
|
||||||
|
}
|
||||||
|
notify({ title: 'Lỗi định vị', message: errorMessage, type: 'error' });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true, // Ưu tiên dùng GPS thay vì Wifi/Cell tower
|
||||||
|
timeout: 10000, // Chờ tối đa 10 giây
|
||||||
|
maximumAge: 0 // Không dùng vị trí cũ trong cache
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 3. Early return phải nằm SAU tất cả các khai báo Hook
|
// 3. Early return phải nằm SAU tất cả các khai báo Hook
|
||||||
if (!isOpen) return null;
|
if (!isOpen || isPublicView) return null; // Do not render if public view
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
if (isStartPoint) {
|
||||||
|
await updateTourStartPoint(tourId, {
|
||||||
|
name: formData.name || "Điểm xuất phát",
|
||||||
|
latitude: parseFloat(formData.latitude as any),
|
||||||
|
longitude: parseFloat(formData.longitude as any),
|
||||||
|
plannedEnd: formData.plannedStart,
|
||||||
|
});
|
||||||
|
} else if (isEndPoint) {
|
||||||
|
await updateTourEndPoint(tourId, {
|
||||||
|
name: formData.name || "Điểm kết thúc",
|
||||||
|
latitude: parseFloat(formData.latitude as any),
|
||||||
|
longitude: parseFloat(formData.longitude as any),
|
||||||
|
plannedStart: formData.plannedStart,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
...formData,
|
...formData,
|
||||||
|
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
|
||||||
legId: currentLegId,
|
legId: currentLegId,
|
||||||
latitude: parseFloat(formData.latitude as any),
|
latitude: parseFloat(formData.latitude as any),
|
||||||
longitude: parseFloat(formData.longitude as any),
|
longitude: parseFloat(formData.longitude as any),
|
||||||
@@ -165,9 +369,16 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
} else {
|
} else {
|
||||||
await addLocation(tourId, payload);
|
await addLocation(tourId, payload);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: isStartPoint ? 'Đã thiết lập điểm xuất phát.' : isEndPoint ? 'Đã thiết lập điểm kết thúc.' : editingLocation ? 'Đã cập nhật địa điểm.' : 'Đã thêm địa điểm mới.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
onSuccess?.(); // Gọi callback onSuccess sau khi thành công
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Lỗi khi lưu địa điểm');
|
notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -176,42 +387,111 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
<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="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">
|
<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}
|
<MapIcon className="w-6 h-6 text-blue-600" /> {titleText}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
<button onClick={onClose} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
|
||||||
<X className="w-6 h-6 text-gray-400" />
|
<X className="w-6 h-6 text-[var(--text-muted)]" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mini Map Picker */}
|
{/* Mini Map Picker */}
|
||||||
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group">
|
<div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-[var(--border)] relative shadow-xl group">
|
||||||
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full">
|
{/* Map Search Bar Overlay - Tích hợp tìm kiếm trực tiếp trên bản đồ */}
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
<div className="absolute top-3 left-3 right-3 z-[1001] pointer-events-none">
|
||||||
<Marker position={[formData.latitude, formData.longitude]} />
|
<div className="relative max-w-sm pointer-events-auto">
|
||||||
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} />
|
<div className="relative group">
|
||||||
|
<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-[var(--text-primary)]"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => handleSearchLocation(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
|
||||||
|
) : formData.name && (
|
||||||
|
<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-[var(--background)] rounded-full text-[var(--text-muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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-[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-[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-[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-[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-[var(--text-muted)] truncate leading-tight">{result.display_name}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false} attributionControl={false}>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
|
||||||
|
<Marker position={currentCoords} />
|
||||||
|
<MapPicker center={currentCoords} onPick={handlePickLocation} />
|
||||||
</MapContainer>
|
</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Í
|
CHUỘT PHẢI ĐỂ CHỌN VỊ TRÍ
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Nút lấy vị trí và thời gian hiện tại - Chỉ dành cho OWNER/MANAGER khi thêm mới */}
|
||||||
|
{!editingLocation && (userRole === 'OWNER' || userRole === 'MANAGER') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUseCurrentLocation}
|
||||||
|
className="w-full mb-6 py-4 bg-indigo-50 hover:bg-indigo-100 text-indigo-600 rounded-2xl flex items-center justify-center gap-2 text-xs font-black uppercase tracking-widest border border-indigo-100 transition-all active:scale-95 shadow-sm"
|
||||||
|
>
|
||||||
|
<Navigation className="w-4 h-4 fill-current" /> Sử dụng vị trí & thời gian hiện tại
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<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 className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
<input
|
||||||
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
|
required
|
||||||
|
placeholder="Tên địa điểm..."
|
||||||
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Address</label>
|
||||||
<input className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
<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})} />
|
value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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-gray-50 border border-gray-100 rounded-xl outline-none resize-none" rows={2}
|
<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..."
|
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})} />
|
value={formData.note} onChange={e => setFormData({...formData, note: e.target.value})} />
|
||||||
</div>
|
</div>
|
||||||
@@ -219,14 +499,18 @@ 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>
|
<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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
|
<label className="block text-xs font-bold text-[var(--text-secondary)] mb-1">Số tiền (VNĐ)</label>
|
||||||
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
<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"
|
placeholder="0"
|
||||||
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} />
|
value={formData.expenseAmount} onChange={e => {
|
||||||
|
const rawValue = e.target.value.replace(/\D/g, ""); // Chỉ lấy số
|
||||||
|
const formattedValue = rawValue.replace(/\B(?=(\d{3})+(?!\d))/g, "."); // Thêm dấu chấm
|
||||||
|
setFormData({...formData, expenseAmount: formattedValue});
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
|
<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-white border border-gray-200 rounded-xl outline-none text-sm"
|
<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})}>
|
value={formData.expenseCategory} onChange={e => setFormData({...formData, expenseCategory: e.target.value})}>
|
||||||
<option value="FOOD">Ăn uống</option>
|
<option value="FOOD">Ăn uống</option>
|
||||||
<option value="TRANSPORT">Di chuyển</option>
|
<option value="TRANSPORT">Di chuyển</option>
|
||||||
@@ -237,57 +521,77 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-600 mb-1">Dịch vụ / Mô tả</label>
|
<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-white border border-gray-200 rounded-xl outline-none text-sm"
|
<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é..."
|
placeholder="Ví dụ: Ăn trưa, taxi, vé..."
|
||||||
value={formData.expenseDescription} onChange={e => setFormData({...formData, expenseDescription: e.target.value})} />
|
value={formData.expenseDescription} onChange={e => setFormData({...formData, expenseDescription: e.target.value})} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-600 mb-1">Ghi chú chi phí</label>
|
<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-white border border-gray-200 rounded-xl outline-none resize-none text-sm" rows={2}
|
<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..."
|
placeholder="Ghi chú thêm..."
|
||||||
value={formData.expenseNote} onChange={e => setFormData({...formData, expenseNote: e.target.value})} />
|
value={formData.expenseNote} onChange={e => setFormData({...formData, expenseNote: e.target.value})} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-600 mb-1">Thành viên đã thanh toán</label>
|
<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-white border border-gray-200 rounded-xl outline-none text-sm"
|
<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})}>
|
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
|
||||||
<option value="">-- Chọn người thanh toán --</option>
|
<option value="">-- Chọn người thanh toán --</option>
|
||||||
{currentTour?.participants?.map((p: any) => {
|
{currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.map((p: any) => {
|
||||||
const name = p.user?.name;
|
const name = p.user?.name || p.displayName;
|
||||||
const email = p.user?.email;
|
const email = p.user?.email;
|
||||||
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
|
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
|
||||||
return (
|
return (
|
||||||
<option key={p.userId} value={p.userId}>{label || p.userId}</option>
|
<option key={p.id} value={p.userId || p.id}>{label || p.userId || p.id}</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-1">Gán vào chặng</label>
|
<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-gray-50 border border-gray-100 rounded-xl outline-none"
|
<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})}>
|
value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}>
|
||||||
{legs.map(leg => (
|
{legs.map(leg => (
|
||||||
<option key={leg.id} value={leg.id}>Chặng {leg.sequence}: {leg.note || 'Không có tên'}</option>
|
<option key={leg.id} value={leg.id}>
|
||||||
|
Chặng {leg.sequence}: {leg.note || 'Không có tên'}
|
||||||
|
{leg.startDate ? ` (${format(parseISO(leg.startDate), 'dd/MM')})` : ''}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-1">Vĩ độ</label>
|
<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-gray-50 border border-gray-100 rounded-xl outline-none"
|
<input
|
||||||
value={formData.latitude} onChange={e => setFormData({...formData, latitude: e.target.value as any})} />
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-1">Kinh độ</label>
|
<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-gray-50 border border-gray-100 rounded-xl outline-none"
|
<input
|
||||||
value={formData.longitude} onChange={e => setFormData({...formData, longitude: e.target.value as any})} />
|
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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-1">Loại</label>
|
<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-gray-50 border border-gray-100 rounded-xl outline-none"
|
<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})}>
|
value={formData.type} onChange={e => setFormData({...formData, type: e.target.value as any})}>
|
||||||
<option value="VISIT">Tham quan</option>
|
<option value="VISIT">Tham quan</option>
|
||||||
<option value="EAT">Ăn uống</option>
|
<option value="EAT">Ăn uống</option>
|
||||||
@@ -297,8 +601,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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="datetime-local" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
<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})} />
|
value={formData.plannedStart} onChange={e => setFormData({...formData, plannedStart: e.target.value})} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react';
|
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock } from 'lucide-react';
|
||||||
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
|
|
||||||
interface AddMemberModalProps {
|
interface AddMemberModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
tourId: string;
|
tourId: string;
|
||||||
participants?: Array<{ userId: string; role: string; user?: { id: string; name: string; email: string } }>;
|
participants?: Array<{ id: string; userId?: string | null; role: string; displayName?: string | null; user?: { id: string; name: string; email: string } | null }>;
|
||||||
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
|
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
|
||||||
onRemoveMember?: (userId: string) => Promise<void>;
|
onRemoveMember?: (userId: string) => Promise<void>;
|
||||||
onMemberAdded?: () => void;
|
onMemberAdded?: () => void;
|
||||||
userRole?: string;
|
userRole?: string; // User's role in the tour
|
||||||
|
isPublicView?: boolean; // New prop to indicate public view
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole, isPublicView }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'search' | 'email'>('search');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -21,15 +25,51 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [fetchError, setFetchError] = useState('');
|
const [fetchError, setFetchError] = useState('');
|
||||||
const [submitError, setSubmitError] = useState('');
|
const [submitError, setSubmitError] = useState('');
|
||||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
|
||||||
const [confirmTarget, setConfirmTarget] = useState<{ userId: string; name: string } | null>(null);
|
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
|
// Trạng thái cho tab mời qua email
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
|
const [inviteRole, setInviteRole] = useState<'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY'>('MEMBER');
|
||||||
|
const [inviteLoading, setInviteLoading] = useState(false);
|
||||||
|
const [inviteError, setInviteError] = useState('');
|
||||||
|
const [inviteSuccess, setInviteSuccess] = useState('');
|
||||||
|
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotification();
|
||||||
|
|
||||||
|
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId).filter(Boolean) as string[]), [participants]);
|
||||||
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
|
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
|
||||||
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||||
|
|
||||||
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
|
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
|
||||||
|
const canInviteByEmail = userRole === 'OWNER' || userRole === 'MANAGER';
|
||||||
|
|
||||||
|
const handleManualAdd = async () => {
|
||||||
|
const name = query.trim();
|
||||||
|
if (!name) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/tours/${tourId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ displayName: name, role }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || data.error || 'Thao tác thất bại');
|
||||||
|
}
|
||||||
|
await onMemberAdded?.();
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setSubmitError(err.message || 'Thao tác thất bại');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -48,10 +88,44 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendInvite = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!inviteEmail.trim()) return;
|
||||||
|
setInviteLoading(true);
|
||||||
|
setInviteError('');
|
||||||
|
setInviteSuccess('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/tours/${tourId}/invitations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || 'Gửi lời mời thất bại');
|
||||||
|
}
|
||||||
|
setInviteSuccess(`Lời mời đã được gửi thành công đến ${inviteEmail}!`);
|
||||||
|
setInviteEmail('');
|
||||||
|
notify({ title: 'Thành công', message: `Lời mời đã gửi tới ${inviteEmail}`, type: 'success' });
|
||||||
|
await onMemberAdded?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setInviteError(err.message || 'Thao tác thất bại');
|
||||||
|
} finally {
|
||||||
|
setInviteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen || activeTab !== 'search') return;
|
||||||
|
const delayDebounceFn = setTimeout(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, [isOpen]);
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(delayDebounceFn);
|
||||||
|
}, [query, isOpen, activeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -60,28 +134,31 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
setRole('MEMBER');
|
setRole('MEMBER');
|
||||||
setFetchError('');
|
setFetchError('');
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
|
setInviteEmail('');
|
||||||
|
setInviteRole('MEMBER');
|
||||||
|
setInviteError('');
|
||||||
|
setInviteSuccess('');
|
||||||
|
setActiveTab('search');
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleRemove = async (userId: string, memberName: string) => {
|
const handleRemove = async (memberIdOrUserId: string, memberName: string) => {
|
||||||
if (!onRemoveMember) return;
|
if (!onRemoveMember) return;
|
||||||
setConfirmTarget({ userId, name: memberName });
|
const isConfirmed = await confirm({
|
||||||
setIsConfirmOpen(true);
|
title: 'Xóa thành viên',
|
||||||
};
|
message: `Bạn có chắc chắn muốn xóa ${memberName} khỏi tour?`
|
||||||
|
});
|
||||||
|
|
||||||
const confirmRemove = async () => {
|
if (isConfirmed) {
|
||||||
if (!confirmTarget || !onRemoveMember) return;
|
|
||||||
try {
|
try {
|
||||||
await onRemoveMember(confirmTarget.userId);
|
await onRemoveMember(memberIdOrUserId);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setSubmitError(err.message || 'Không thể xóa thành viên');
|
setSubmitError(err.message || 'Không thể xóa thành viên');
|
||||||
} finally {
|
}
|
||||||
setIsConfirmOpen(false);
|
|
||||||
setConfirmTarget(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRequestAction = async (reqId: string, action: 'accept' | 'reject', userName: string) => {
|
const handleRequestAction = async (reqId: string, action: 'accept' | 'reject') => {
|
||||||
if (!onMemberAdded) return;
|
if (!onMemberAdded) return;
|
||||||
setActionLoading(reqId);
|
setActionLoading(reqId);
|
||||||
try {
|
try {
|
||||||
@@ -98,7 +175,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
}
|
}
|
||||||
await onMemberAdded();
|
await onMemberAdded();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Thao tác thất bại');
|
notify({ title: 'Lỗi', message: err.message || 'Thao tác thất bại', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -134,7 +211,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen || isPublicView) return null; // Do not render if public view
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||||
@@ -143,10 +220,10 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||||
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'}
|
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thành viên hành trình' : 'Mời tham gia tour'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
|
{canCreateDirectly ? 'Quản lý, thêm thành viên và mời người khác tham gia.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
|
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
|
||||||
@@ -154,11 +231,37 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 space-y-4">
|
{canInviteByEmail && (
|
||||||
|
<div className="flex border-b border-gray-100 bg-gray-50/30">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('search')}
|
||||||
|
className={`flex-1 py-3 text-center text-sm font-bold border-b-2 transition-all ${
|
||||||
|
activeTab === 'search'
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tìm thành viên
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('email')}
|
||||||
|
className={`flex-1 py-3 text-center text-sm font-bold border-b-2 transition-all ${
|
||||||
|
activeTab === 'email'
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mời qua Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4 overflow-y-auto flex-1">
|
||||||
|
{/* Danh sách thành viên hiện tại */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p>
|
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.filter(p => p.user || p.displayName).length})</p>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{participants.map((p) => {
|
{participants.filter(p => p.user || p.displayName).map((p) => {
|
||||||
const rawToken = localStorage.getItem('token');
|
const rawToken = localStorage.getItem('token');
|
||||||
let currentUserId: string | null = null;
|
let currentUserId: string | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -170,15 +273,16 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
const isCurrentUser = currentUserId && p.userId === currentUserId;
|
const isCurrentUser = currentUserId && p.userId === currentUserId;
|
||||||
const isOwner = p.role === 'OWNER';
|
const isOwner = p.role === 'OWNER';
|
||||||
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
|
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
|
||||||
|
const memberName = p.user?.name || p.displayName || p.userId || 'Thành viên';
|
||||||
return (
|
return (
|
||||||
<div key={p.userId} className="flex flex-col items-center gap-1">
|
<div key={p.id} className="flex flex-col items-center gap-1">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
|
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
|
||||||
{p.user?.name?.charAt(0) || '?'}
|
{memberName.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
{canRemove && (
|
{canRemove && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemove(p.userId, p.user?.name || p.userId)}
|
onClick={() => handleRemove(p.userId || p.id, memberName)}
|
||||||
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-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
|
||||||
aria-label="Remove item"
|
aria-label="Remove item"
|
||||||
>
|
>
|
||||||
@@ -186,7 +290,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{p.user?.name || p.userId}</span>
|
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{memberName}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -196,6 +300,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Danh sách chờ duyệt */}
|
||||||
{joinRequests.length > 0 && (
|
{joinRequests.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1">
|
<p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1">
|
||||||
@@ -211,7 +316,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionLoading === req.id}
|
disabled={actionLoading === req.id}
|
||||||
onClick={() => handleRequestAction(req.id, 'accept', req.user?.name || req.userId)}
|
onClick={() => handleRequestAction(req.id, 'accept')}
|
||||||
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
|
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
|
||||||
aria-label="Accept"
|
aria-label="Accept"
|
||||||
>
|
>
|
||||||
@@ -220,7 +325,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionLoading === req.id}
|
disabled={actionLoading === req.id}
|
||||||
onClick={() => handleRequestAction(req.id, 'reject', req.user?.name || req.userId)}
|
onClick={() => handleRequestAction(req.id, 'reject')}
|
||||||
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
|
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
|
||||||
aria-label="Reject"
|
aria-label="Reject"
|
||||||
>
|
>
|
||||||
@@ -235,11 +340,15 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<hr className="border-gray-100" />
|
||||||
|
|
||||||
|
{activeTab === 'search' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
{canCreateDirectly && (
|
{canCreateDirectly && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền</label>
|
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền khi thêm</label>
|
||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm font-bold text-gray-800"
|
||||||
value={role}
|
value={role}
|
||||||
onChange={(e) => setRole(e.target.value as any)}
|
onChange={(e) => setRole(e.target.value as any)}
|
||||||
>
|
>
|
||||||
@@ -253,6 +362,26 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="relative mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tìm kiếm theo tên hoặc địa chỉ email..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-10 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
|
||||||
|
/>
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-200 rounded-full text-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{fetchError && (
|
{fetchError && (
|
||||||
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
|
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
|
||||||
{fetchError}
|
{fetchError}
|
||||||
@@ -266,7 +395,28 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
|
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
|
<div className="space-y-2 max-h-[30vh] overflow-y-auto pr-1">
|
||||||
|
{query.trim() && canCreateDirectly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleManualAdd}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-2xl border border-dashed border-blue-300 hover:border-blue-400 bg-blue-50/20 text-blue-700 transition-all text-left mb-2"
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="text-sm font-bold">Thêm thành viên thủ công</div>
|
||||||
|
<div className="text-[11px] text-gray-500">Thêm "{query.trim()}" trực tiếp vào danh sách thành viên</div>
|
||||||
|
</div>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<span className="px-2.5 py-1 bg-blue-600 text-white rounded-xl text-xs font-bold transition-all">Thêm</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{visibleUsers.map((u) => {
|
{visibleUsers.map((u) => {
|
||||||
const isSelected = selectedUser === u.id;
|
const isSelected = selectedUser === u.id;
|
||||||
return (
|
return (
|
||||||
@@ -284,14 +434,8 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
<div className="flex-1 text-left">
|
<div className="flex-1 text-left">
|
||||||
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
|
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
|
||||||
<div className="text-[11px] text-gray-500">{u.email}</div>
|
<div className="text-[11px] text-gray-500">{u.email}</div>
|
||||||
<div className="text-[11px] text-gray-400">{u.phone || ''} {u.address ? `• ${u.address}` : ''}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
|
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
|
||||||
{u.isAdmin ? (
|
|
||||||
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100">ADMIN</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100">USER</span>
|
|
||||||
)}
|
|
||||||
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
|
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -303,9 +447,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
|
||||||
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
|
|
||||||
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
||||||
Hủy
|
Hủy
|
||||||
</button>
|
</button>
|
||||||
@@ -318,26 +460,72 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSendInvite} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-gray-700 ml-1">Email người nhận</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
placeholder="nhap.email@example.com"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isConfirmOpen && (
|
<div className="space-y-1.5">
|
||||||
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
|
<label className="text-xs font-bold text-gray-700 ml-1">Vai trò trong Tour</label>
|
||||||
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={() => setIsConfirmOpen(false)} />
|
<select
|
||||||
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
|
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm font-bold text-gray-800"
|
||||||
<h3 className="text-base font-bold text-gray-900">Xác nhận xóa thành viên</h3>
|
value={inviteRole}
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
onChange={(e) => setInviteRole(e.target.value as any)}
|
||||||
Bạn có chắc muốn xóa <span className="font-semibold text-gray-800">{confirmTarget?.name}</span> khỏi tour này?
|
>
|
||||||
</p>
|
<option value="MEMBER">MEMBER (Thành viên tài chính)</option>
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE (Thành viên phi tài chính)</option>
|
||||||
<button onClick={() => setIsConfirmOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
<option value="MANAGER">MANAGER (Đồng quản trị viên)</option>
|
||||||
Hủy
|
<option value="VIEWER_ONLY">VIEWER_ONLY (Chỉ xem thông tin)</option>
|
||||||
</button>
|
</select>
|
||||||
<button onClick={confirmRemove} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
|
|
||||||
Xóa
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{inviteError && (
|
||||||
|
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
|
||||||
|
{inviteError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{inviteSuccess && (
|
||||||
|
<div className="p-3 bg-green-50 text-green-700 rounded-xl text-xs font-bold border border-green-100">
|
||||||
|
{inviteSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
Đóng
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={inviteLoading || !inviteEmail}
|
||||||
|
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{inviteLoading ? (
|
||||||
|
<>
|
||||||
|
Đang gửi...
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Gửi thư mời'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
import { processImageModeration } from '@/hooks/useImageModeration';
|
||||||
|
import { compressImage } from '../utils/image';
|
||||||
|
|
||||||
|
interface AddPhotoModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tourId: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
isPublicView?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => {
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
|
const [previews, setPreviews] = useState<string[]>([]);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const notify = useNotification();
|
||||||
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
const newValidFiles: File[] = [];
|
||||||
|
const newValidPreviews: string[] = [];
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
notify({ title: 'Đang kiểm duyệt...', message: 'Đang kiểm tra và lọc hình ảnh của bạn...', type: 'info' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
|
||||||
|
if (!file || file.size === 0) {
|
||||||
|
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nén ảnh trước
|
||||||
|
const compressedFile = await compressImage(file);
|
||||||
|
|
||||||
|
// 2. Chạy kiểm duyệt hình ảnh
|
||||||
|
const moderationResult = await processImageModeration(compressedFile);
|
||||||
|
if (moderationResult.blocked) {
|
||||||
|
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedFile = moderationResult.file;
|
||||||
|
const previewUrl = URL.createObjectURL(processedFile);
|
||||||
|
|
||||||
|
// 3. Kiểm tra tính toàn vẹn: Thử load ảnh vào bộ nhớ để xác nhận file "tồn tại" và đọc được
|
||||||
|
const isValidImage = await new Promise<boolean>((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(true);
|
||||||
|
img.onerror = () => resolve(false);
|
||||||
|
img.src = previewUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isValidImage) {
|
||||||
|
newValidFiles.push(processedFile);
|
||||||
|
newValidPreviews.push(previewUrl);
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
|
||||||
|
notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFiles(prev => [...prev, ...newValidFiles]);
|
||||||
|
setPreviews(prev => [...prev, ...newValidPreviews]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('File checking error:', err);
|
||||||
|
notify({ title: 'Lỗi', message: 'Lỗi trong quá trình kiểm duyệt ảnh.', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
// Thu hồi URL khi xóa khỏi danh sách chờ để giải phóng bộ nhớ
|
||||||
|
URL.revokeObjectURL(previews[index]);
|
||||||
|
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
|
setPreviews(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
// Lấy tọa độ hiện tại của người dùng làm dự phòng nếu ảnh EXIF không có GPS
|
||||||
|
const location = await Promise.race([
|
||||||
|
new Promise<GeolocationPosition | null>((resolve) => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
resolve(null);
|
||||||
|
} else {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => resolve(pos),
|
||||||
|
() => resolve(null),
|
||||||
|
{ timeout: 4000, enableHighAccuracy: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
|
||||||
|
]);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
selectedFiles.forEach(file => {
|
||||||
|
formData.append('images', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
formData.append('latitude', location.coords.latitude.toString());
|
||||||
|
formData.append('longitude', location.coords.longitude.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Upload failed');
|
||||||
|
|
||||||
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: `Đã tải lên ${selectedFiles.length} ảnh.`,
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Giải phóng bộ nhớ sau khi hoàn tất
|
||||||
|
previews.forEach(url => URL.revokeObjectURL(url));
|
||||||
|
setSelectedFiles([]);
|
||||||
|
setPreviews([]);
|
||||||
|
|
||||||
|
// Refresh tour data (applies to both authenticated and public users)
|
||||||
|
// This ensures newly uploaded photos appear immediately without requiring a page reload
|
||||||
|
fetchTour(tourId);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
|
||||||
|
// For public users: redirect to landing page after upload (after data refresh)
|
||||||
|
// For authenticated users: close modal and show updated tour
|
||||||
|
if (isPublicView) {
|
||||||
|
onClose();
|
||||||
|
// Give time for tour data to refresh before redirecting
|
||||||
|
setTimeout(() => {
|
||||||
|
localStorage.setItem('fromPublicUpload', 'true');
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notify({
|
||||||
|
title: 'Lỗi',
|
||||||
|
message: 'Không thể tải ảnh lên. Vui lòng thử lại.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
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-[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-[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-[var(--background)] rounded-full transition-colors text-[var(--text-muted)]">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
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-[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-[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-[var(--border)] shadow-sm group">
|
||||||
|
<img src={src} className="w-full h-full object-cover" alt="preview" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFile(idx)}
|
||||||
|
className="absolute top-1.5 right-1.5 p-1.5 bg-red-500/80 backdrop-blur-sm text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={isUploading || isProcessing || selectedFiles.length === 0}
|
||||||
|
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-black uppercase tracking-widest rounded-2xl flex items-center justify-center gap-2 shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : isProcessing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Đang xử lý ảnh...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Xác nhận tải lên'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
locationId: string;
|
||||||
|
locationName: string;
|
||||||
|
onCommentAdded?: () => void; // Callback to update comment count on parent
|
||||||
|
onCommentDeleted?: () => void;
|
||||||
|
isPublicView?: boolean; // New prop to indicate public view
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => {
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [confirmState, setConfirmState] = useState({ open: false, commentId: '' });
|
||||||
|
|
||||||
|
const userRole = useTourStore(state => state.userRole);
|
||||||
|
const currentUserId = React.useMemo(() => {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
|
return user.id;
|
||||||
|
} catch { return null; }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchComments = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setComments(data.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
userName: c.user?.name || 'Ẩn danh',
|
||||||
|
content: c.content,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
userId: c.userId
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi khi tải bình luận:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !locationId) return;
|
||||||
|
|
||||||
|
fetchComments();
|
||||||
|
|
||||||
|
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
|
||||||
|
const socket = io();
|
||||||
|
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
|
||||||
|
|
||||||
|
socket.on('commentAdded', (newCommentData: any) => {
|
||||||
|
if (newCommentData.locationId === locationId) {
|
||||||
|
setComments(prev => {
|
||||||
|
// Tránh trùng lặp nếu chính mình gửi
|
||||||
|
if (prev.find(c => c.id === newCommentData.id)) return prev;
|
||||||
|
return [...prev, {
|
||||||
|
id: newCommentData.id,
|
||||||
|
userName: newCommentData.user?.name || 'Ẩn danh',
|
||||||
|
content: newCommentData.content,
|
||||||
|
createdAt: newCommentData.createdAt,
|
||||||
|
userId: newCommentData.userId || newCommentData.user?.id
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { socket.disconnect(); };
|
||||||
|
}, [isOpen, locationId]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!newComment.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: newComment })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setNewComment('');
|
||||||
|
fetchComments();
|
||||||
|
onCommentAdded?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi khi gửi bình luận:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (commentId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/locations/comments/${commentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setComments(prev => prev.filter(c => c.id !== commentId));
|
||||||
|
onCommentDeleted?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi khi xóa bình luận:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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-[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-[var(--border)] flex justify-between items-center bg-[var(--surface)] sticky top-0 z-10">
|
||||||
|
<div>
|
||||||
|
<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-[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-[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-[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-[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">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 border border-blue-200">
|
||||||
|
<User className="w-4 h-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<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-[var(--text-primary)]">{c.userName}</p>
|
||||||
|
{(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmState({ open: true, commentId: c.id })}
|
||||||
|
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-[var(--text-secondary)] leading-relaxed">{c.content}</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="p-4 bg-[var(--surface)] border-t border-[var(--border)]">
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newComment}
|
||||||
|
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-[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" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmState.open}
|
||||||
|
title="Xóa bình luận"
|
||||||
|
message="Bạn có chắc chắn muốn xóa bình luận này không? Hành động này sẽ không thể hoàn tác."
|
||||||
|
onConfirm={() => {
|
||||||
|
handleDelete(confirmState.commentId);
|
||||||
|
setConfirmState({ open: false, commentId: '' });
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmState({ open: false, commentId: '' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,39 +1,50 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { AlertTriangle, X } from 'lucide-react';
|
||||||
|
|
||||||
interface ConfirmModalProps {
|
interface ConfirmModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
message: string;
|
message?: string;
|
||||||
confirmText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
export const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, title, message, onConfirm, onCancel }) => {
|
||||||
isOpen,
|
|
||||||
title = 'Xác nhận',
|
|
||||||
message,
|
|
||||||
confirmText = 'Xác nhận',
|
|
||||||
cancelText = 'Hủy',
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
}) => {
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[2200] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4">
|
||||||
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={onCancel} />
|
{/* Backdrop */}
|
||||||
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onCancel} />
|
||||||
<h3 className="text-base font-bold text-gray-900">{title}</h3>
|
|
||||||
<p className="mt-2 text-sm text-gray-600">{message}</p>
|
{/* Modal Content */}
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="relative w-full max-w-sm bg-[var(--surface)] rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||||
<button onClick={onCancel} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
<div className="flex justify-between items-center mb-4">
|
||||||
{cancelText}
|
<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-[var(--background)] rounded-full transition-colors">
|
||||||
|
<X className="w-5 h-5 text-[var(--text-muted)]" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onConfirm} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
|
</div>
|
||||||
{confirmText}
|
|
||||||
|
<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-[var(--background)] hover:bg-[var(--background)] text-[var(--text-secondary)] font-bold rounded-2xl transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Hủy bỏ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="py-4 bg-red-600 hover:bg-red-700 text-white font-bold rounded-2xl shadow-lg shadow-red-100 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Xác nhận
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, MapPin, Search, Loader2 } from 'lucide-react';
|
||||||
|
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
|
||||||
|
// Fix Leaflet default marker icon bug
|
||||||
|
const DefaultIcon = L.icon({
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CoordinateSelectModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialLat?: number;
|
||||||
|
initialLng?: number;
|
||||||
|
onSelect: (lat: number, lng: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapClickEvents({ onClick }: { onClick: (lat: number, lng: number) => void }) {
|
||||||
|
useMapEvents({
|
||||||
|
click: (e) => {
|
||||||
|
onClick(e.latlng.lat, e.latlng.lng);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapInvalidator() {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
map.invalidateSize();
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecenterMap({ position }: { position: [number, number] }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
map.setView(position, map.getZoom());
|
||||||
|
}, [position, map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialLat,
|
||||||
|
initialLng,
|
||||||
|
onSelect
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
|
||||||
|
const [position, setPosition] = useState<[number, number]>(defaultCenter);
|
||||||
|
const [hasSelected, setHasSelected] = useState(false);
|
||||||
|
|
||||||
|
// Search States
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
|
||||||
|
setPosition([initialLat, initialLng]);
|
||||||
|
setHasSelected(true);
|
||||||
|
} else {
|
||||||
|
setPosition(defaultCenter);
|
||||||
|
setHasSelected(false);
|
||||||
|
}
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}, [isOpen, initialLat, initialLng]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleMapClick = (lat: number, lng: number) => {
|
||||||
|
setPosition([lat, lng]);
|
||||||
|
setHasSelected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onSelect(position[0], position[1]);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&accept-language=vi&limit=5`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSearchResults(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error during Nominatim search:', e);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectResult = (place: any) => {
|
||||||
|
const lat = parseFloat(place.lat);
|
||||||
|
const lng = parseFloat(place.lon);
|
||||||
|
setPosition([lat, lng]);
|
||||||
|
setHasSelected(true);
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearchQuery(place.display_name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative w-full max-w-2xl h-[550px] bg-white dark:bg-slate-900 rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 dark:border-slate-800 animate-in zoom-in-95 duration-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-gray-100 dark:border-slate-800/80 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-blue-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="font-extrabold text-sm text-gray-900 dark:text-white">{t('chooseLocationMap')}</h3>
|
||||||
|
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">{t('clickMapSelectCoords')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-all text-gray-400 hover:text-gray-650"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Body */}
|
||||||
|
<div className="flex-1 bg-gray-50 dark:bg-slate-950 relative min-h-[300px]" style={{ zIndex: 10 }}>
|
||||||
|
|
||||||
|
{/* Floating Search Panel */}
|
||||||
|
<div className="absolute top-4 left-4 right-4 sm:right-auto z-[1000] sm:w-80 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md rounded-2xl border border-slate-150 dark:border-slate-800 shadow-xl p-2 flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 relative flex items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
placeholder={t('searchPlaceholder')}
|
||||||
|
className="w-full bg-slate-50 dark:bg-slate-800 border-0 outline-none rounded-xl pl-8 pr-3 py-2 text-xs text-slate-800 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={isSearching}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-bold px-3 py-2 rounded-xl text-xs transition-all active:scale-95 shrink-0 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{isSearching ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : t('confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="max-h-48 overflow-y-auto divide-y divide-gray-100 dark:divide-slate-800/50 bg-white dark:bg-slate-900 rounded-xl border border-slate-150 dark:border-slate-800 shadow-inner">
|
||||||
|
{searchResults.map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectResult(r)}
|
||||||
|
className="w-full text-left px-3 py-2.5 text-[10px] text-gray-700 dark:text-slate-350 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors truncate block"
|
||||||
|
title={r.display_name}
|
||||||
|
>
|
||||||
|
{r.display_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapContainer
|
||||||
|
center={position}
|
||||||
|
zoom={13}
|
||||||
|
attributionControl={false}
|
||||||
|
style={{ width: '100%', height: '100%', zIndex: 1 }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<MapClickEvents onClick={handleMapClick} />
|
||||||
|
<MapInvalidator />
|
||||||
|
<RecenterMap position={position} />
|
||||||
|
{hasSelected && (
|
||||||
|
<Marker position={position} icon={DefaultIcon} />
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-100 dark:border-slate-800/80 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-slate-400">
|
||||||
|
{hasSelected ? (
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-slate-200">
|
||||||
|
{t('coordsLabel')}: {position[0].toFixed(6)}, {position[1].toFixed(6)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="italic text-gray-400 dark:text-slate-500">{t('noCoordsSelected')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 border border-gray-200 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800 text-gray-700 dark:text-slate-300 rounded-xl text-xs font-bold transition-all animate-fade-in"
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!hasSelected}
|
||||||
|
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 dark:disabled:bg-slate-800 dark:disabled:text-slate-650 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
|
||||||
|
>
|
||||||
|
{t('confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Trash2, Users } from 'lucide-react';
|
import { Trash2, Users, Tag as TagIcon } from 'lucide-react';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
|
||||||
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
|
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
const [adultCount, setAdultCount] = useState(2);
|
const [adultCount, setAdultCount] = useState(2);
|
||||||
@@ -12,6 +13,10 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const createTour = useTourStore((state) => state.createTour);
|
const createTour = useTourStore((state) => state.createTour);
|
||||||
|
|
||||||
|
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [customTag, setCustomTag] = useState('');
|
||||||
|
|
||||||
const [members, setMembers] = useState<any[]>([]);
|
const [members, setMembers] = useState<any[]>([]);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<any[]>([]);
|
const [results, setResults] = useState<any[]>([]);
|
||||||
@@ -49,20 +54,42 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
|||||||
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
setSelectedTags(prev =>
|
||||||
|
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomTag = () => {
|
||||||
|
const tag = customTag.trim();
|
||||||
|
if (tag && !selectedTags.includes(tag)) {
|
||||||
|
setSelectedTags([...selectedTags, tag]);
|
||||||
|
setCustomTag('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const memberIds = members.map((m) => m.id);
|
const membersPayload = members.map((m) => {
|
||||||
|
if (m.isManual) {
|
||||||
|
return { displayName: m.name };
|
||||||
|
} else {
|
||||||
|
return { userId: m.id };
|
||||||
|
}
|
||||||
|
});
|
||||||
const tour = await createTour({
|
const tour = await createTour({
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
memberIds,
|
members: membersPayload,
|
||||||
adultCount,
|
adultCount,
|
||||||
childCount,
|
childCount,
|
||||||
childDiscount
|
childDiscount,
|
||||||
|
tags: selectedTags
|
||||||
});
|
});
|
||||||
onSuccess(tour);
|
onSuccess(tour);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -76,39 +103,89 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
<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="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6">
|
<div className="relative w-full max-w-md bg-[var(--surface)] rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<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-gray-900">Tạo Tour mới</h2>
|
<h2 className="text-xl font-bold text-[var(--text-primary)]">Tạo Tour mới</h2>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">✕</button>
|
<button onClick={onClose} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="p-5 space-y-4 overflow-y-auto flex-1">
|
||||||
<div>
|
<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
|
<input
|
||||||
required
|
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"
|
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}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="VD: Khám phá Đà Lạt"
|
placeholder="VD: Khám phá Đà Lạt"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
{availableTags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
|
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-[var(--surface)] text-[var(--text-secondary)] border-[var(--border)] hover:border-blue-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customTag}
|
||||||
|
onChange={(e) => setCustomTag(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustomTag())}
|
||||||
|
placeholder="Thêm nhãn tùy chỉnh..."
|
||||||
|
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"
|
||||||
|
onClick={addCustomTag}
|
||||||
|
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all"
|
||||||
|
>
|
||||||
|
Thêm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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-[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)}
|
||||||
|
placeholder="Viết vài dòng giới thiệu về hành trình..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="date"
|
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"
|
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}
|
value={startDate}
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="date"
|
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"
|
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}
|
value={endDate}
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -122,68 +199,90 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Người lớn</label>
|
<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-white border border-gray-200 rounded-xl outline-none text-sm"
|
<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))} />
|
value={adultCount} onChange={e => setAdultCount(Number(e.target.value))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Trẻ em</label>
|
<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-white border border-gray-200 rounded-xl outline-none text-sm"
|
<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))} />
|
value={childCount} onChange={e => setChildCount(Number(e.target.value))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Giảm trẻ em %</label>
|
<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-white border border-gray-200 rounded-xl outline-none text-sm"
|
<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))} />
|
value={childDiscount} onChange={e => setChildDiscount(Number(e.target.value))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-blue-400 italic font-medium">* Dùng để tính toán đơn giá bình quân trong báo cáo chi phí.</p>
|
<p className="text-[10px] text-blue-400 italic font-medium">* Dùng để tính toán đơn giá bình quân trong báo cáo chi phí.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label>
|
<label className="block text-sm font-bold text-[var(--text-secondary)]">Thành viên tham gia ({members.length})</label>
|
||||||
<div className="flex flex-wrap gap-3">
|
{members.length > 0 && (
|
||||||
{members.map((m) => (
|
<div className="flex flex-wrap gap-3 mb-3 p-3 bg-[var(--background)] rounded-2xl border border-[var(--border)]">
|
||||||
<div key={m.id} className="relative">
|
{members.map((m) => {
|
||||||
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
|
const initial = m.name?.charAt(0) || '?';
|
||||||
{m.name}
|
return (
|
||||||
|
<div key={m.id} className="flex flex-col items-center gap-1">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
|
||||||
|
{initial}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeMember(m.id)}
|
onClick={() => removeMember(m.id)}
|
||||||
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors"
|
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"
|
aria-label="Remove item"
|
||||||
>
|
>
|
||||||
<Trash2 size={12} />
|
<Trash2 size={10} />
|
||||||
</button>
|
</button>
|
||||||
<div className="text-[10px] text-center mt-1 max-w-[70px] truncate">{m.name}</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<span className="text-[10px] font-semibold text-[var(--text-secondary)] max-w-[72px] truncate">{m.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
className="w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none"
|
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..."
|
placeholder="Tìm email hoặc nhập tên thành viên ngoài hệ thống..."
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => searchUsers(e.target.value)}
|
onChange={(e) => searchUsers(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{results.length > 0 && (
|
{(results.length > 0 || query.trim()) && (
|
||||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto">
|
<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">
|
||||||
{results.map((u) => (
|
{query.trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const name = query.trim();
|
||||||
|
setMembers((prev) => (prev.some((m) => m.name.toLowerCase() === name.toLowerCase()) ? prev : [...prev, { id: `manual-${Date.now()}`, name, isManual: true }]));
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{results.filter(u => !members.some(m => m.id === u.id)).map((u) => (
|
||||||
<button
|
<button
|
||||||
key={u.id}
|
key={u.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => confirmAddMember(u)}
|
onClick={() => confirmAddMember(u)}
|
||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50"
|
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}</span>
|
<span className="font-bold text-[var(--text-primary)]">{u.name || 'Chưa đặt tên'}</span>
|
||||||
<span className="block text-xs text-gray-500">{u.email}</span>
|
<span className="text-xs text-[var(--text-muted)]">{u.email}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
|
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft } 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 { useTourStore } from '@/store/useTourStore';
|
||||||
import { ConfirmModal } from '@/components/ConfirmModal';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
|
import { CommentModal } from '@/components/CommentModal';
|
||||||
|
|
||||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||||
if (!actual) return null;
|
if (!actual) return null;
|
||||||
@@ -38,22 +40,77 @@ const formatTravelTime = (minutes: number) => {
|
|||||||
|
|
||||||
export const ItineraryTimeline = ({
|
export const ItineraryTimeline = ({
|
||||||
onAddLocation,
|
onAddLocation,
|
||||||
onEditLocation
|
onEditLocation,
|
||||||
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
|
onQuickNote,
|
||||||
|
onNavigate,
|
||||||
|
onSuccess,
|
||||||
|
isPublicView = false
|
||||||
|
}: {
|
||||||
|
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
|
||||||
|
onEditLocation?: (location: any) => 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
|
||||||
|
}) => {
|
||||||
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
|
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
|
||||||
const currentTour = useTourStore(state => state.currentTour);
|
const currentTour = useTourStore(state => state.currentTour);
|
||||||
const legs = useTourStore(state => state.legs);
|
const legs = useTourStore(state => state.legs);
|
||||||
const userRole = useTourStore(state => state.userRole);
|
const userRole = useTourStore(state => state.userRole);
|
||||||
const optimizeRouting = useTourStore(state => state.optimizeRouting);
|
|
||||||
const addLeg = useTourStore(state => state.addLeg);
|
const addLeg = useTourStore(state => state.addLeg);
|
||||||
const updateLeg = useTourStore(state => state.updateLeg);
|
const updateLeg = useTourStore(state => state.updateLeg);
|
||||||
|
// Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop
|
||||||
const deleteLeg = useTourStore(state => state.deleteLeg);
|
const deleteLeg = useTourStore(state => state.deleteLeg);
|
||||||
const initializeLegs = useTourStore(state => state.initializeLegs);
|
const initializeLegs = useTourStore(state => state.initializeLegs);
|
||||||
const deleteLocation = useTourStore(state => state.deleteLocation);
|
const deleteLocation = useTourStore(state => state.deleteLocation);
|
||||||
|
|
||||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
// Khai báo logic canEdit để sử dụng trong toàn bộ component
|
||||||
|
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
|
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotification();
|
||||||
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
|
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
|
||||||
const [tempLegCount, setTempLegCount] = useState(3);
|
const [tempLegCount, setTempLegCount] = useState(3);
|
||||||
|
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
||||||
|
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;
|
||||||
|
const updatedLegs = currentLegs.map((leg: any) => ({
|
||||||
|
...leg,
|
||||||
|
locations: leg.locations.map((loc: any) =>
|
||||||
|
loc.id === locationId
|
||||||
|
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
|
||||||
|
: loc
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
// Dùng setState của Zustand để cập nhật một phần dữ liệu
|
||||||
|
useTourStore.setState({ legs: updatedLegs });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommentDecrement = (locationId: string) => {
|
||||||
|
const currentLegs = useTourStore.getState().legs;
|
||||||
|
const updatedLegs = currentLegs.map((leg: any) => ({
|
||||||
|
...leg,
|
||||||
|
locations: leg.locations.map((loc: any) =>
|
||||||
|
loc.id === locationId
|
||||||
|
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
|
||||||
|
: loc
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
useTourStore.setState({ legs: updatedLegs });
|
||||||
|
};
|
||||||
|
|
||||||
// State cho Modal sửa chặng
|
// State cho Modal sửa chặng
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
@@ -65,9 +122,44 @@ export const ItineraryTimeline = ({
|
|||||||
endDate: ''
|
endDate: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleComplete = async (locationId: string) => {
|
const handleStatusClick = (location: any) => {
|
||||||
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
|
if (onNavigate) {
|
||||||
console.log("Toggle status for location:", locationId);
|
onNavigate(location);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 handleAddLeg = async () => {
|
||||||
@@ -113,40 +205,61 @@ export const ItineraryTimeline = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLeg = async (legId: string) => {
|
const handleDeleteLeg = async (legId: string) => {
|
||||||
setConfirmState({
|
const isConfirmed = await confirm({
|
||||||
open: true,
|
|
||||||
title: 'Xóa chặng',
|
title: 'Xóa chặng',
|
||||||
message: 'Bạn có chắc chắn muốn xóa chặng này?',
|
message: 'Bạn có chắc chắn muốn xóa chặng này?'
|
||||||
onConfirm: async () => {
|
});
|
||||||
|
if (isConfirmed) {
|
||||||
try {
|
try {
|
||||||
await deleteLeg(legId);
|
await deleteLeg(legId);
|
||||||
|
onSuccess?.();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message);
|
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
||||||
} finally {
|
}
|
||||||
setConfirmState({ open: false });
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLocation = async (id: string) => {
|
const handleDeleteLocation = async (id: string) => {
|
||||||
setConfirmState({
|
const isConfirmed = await confirm({
|
||||||
open: true,
|
|
||||||
title: 'Xóa địa điểm',
|
title: 'Xóa địa điểm',
|
||||||
message: 'Bạn có chắc chắn muốn xóa địa điểm này?',
|
message: 'Bạn có chắc chắn muốn xóa địa điểm này?'
|
||||||
onConfirm: async () => {
|
});
|
||||||
|
if (isConfirmed) {
|
||||||
try {
|
try {
|
||||||
await deleteLocation(id);
|
await deleteLocation(id);
|
||||||
} catch (err: any) { alert(err.message); } finally {
|
onSuccess?.();
|
||||||
setConfirmState({ open: false });
|
} catch (err: any) {
|
||||||
|
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tạo mảng phẳng tất cả địa điểm để tính toán quãng đường liên tục giữa các chặng
|
||||||
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 (
|
return (
|
||||||
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
<div id="itinerary-timeline-print-zone" className="timeline-scroll-container itinerary-timeline-container">
|
||||||
<div className="px-2 pt-4">
|
|
||||||
{legs.length === 0 ? (
|
{legs.length === 0 ? (
|
||||||
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
<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" />
|
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||||
@@ -165,15 +278,27 @@ export const ItineraryTimeline = ({
|
|||||||
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
<section key={leg.id} id={`leg-anchor-node-${leg.id}`} className={`folder-node-wrapper scroll-mt-[70px] ${expandedStageId === leg.id ? 'expanded' : 'collapsed'}`}>
|
||||||
{/* Leg Header */}
|
{/* Folder header row - clickable to toggle exclusive expansion */}
|
||||||
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
|
<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="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm shrink-0">
|
||||||
{leg.sequence}
|
{leg.sequence}
|
||||||
</span>
|
</span>
|
||||||
{leg.note || `Chi tiết Chặng ${leg.sequence}`}
|
<div className="flex flex-col overflow-hidden">
|
||||||
|
<span className="truncate leading-tight">{leg.note || `Chi tiết Chặng ${leg.sequence}`}</span>
|
||||||
|
{leg.startDate && (
|
||||||
|
<span className="text-[10px] text-gray-400 font-black uppercase tracking-wider flex items-center gap-1 mt-0.5">
|
||||||
|
<CalendarIcon className="w-2.5 h-2.5" />
|
||||||
|
{format(parseISO(leg.startDate), 'dd/MM/yyyy')}
|
||||||
|
{leg.endDate && leg.endDate !== leg.startDate && ` - ${format(parseISO(leg.endDate), 'dd/MM/yyyy')}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{prevLegLastLoc && (
|
{prevLegLastLoc && (
|
||||||
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
|
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
|
||||||
@@ -181,32 +306,47 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
{/* Expand/Collapse indicator button */}
|
||||||
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
<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
|
<button
|
||||||
onClick={() => onAddLocation?.(leg.id)}
|
onClick={(e) => { e.stopPropagation(); 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"
|
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"
|
title="Thêm địa điểm vào chặng này"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditLeg(leg)}
|
onClick={(e) => { e.stopPropagation(); handleEditLeg(leg); }}
|
||||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
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" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteLeg(leg.id)}
|
onClick={(e) => { e.stopPropagation(); handleDeleteLeg(leg.id); }}
|
||||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
|
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{leg.totalDistance !== undefined && (
|
{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">
|
<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
|
{leg.totalDistance} km
|
||||||
</div>
|
</div>
|
||||||
@@ -217,54 +357,93 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{totalDwellMinutes > 0 && (
|
{totalDwellMinutes > 0 && (
|
||||||
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
|
<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" />
|
<Clock className="w-3 h-3" />
|
||||||
Dừng: {formatTravelTime(totalDwellMinutes)}
|
Dừng: {formatTravelTime(totalDwellMinutes)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => optimizeRouting(leg.id)}
|
|
||||||
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<Zap className="w-3 h-3" />
|
|
||||||
Tối ưu
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vertical Line for the whole leg */}
|
{/* Scrollable content body with proper z-index layering */}
|
||||||
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
|
<div className="child-nodes-list-wrapper">
|
||||||
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
|
{/* 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">
|
<div className="ml-2">
|
||||||
{leg.locations.map((location, idx) => {
|
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
|
||||||
// Logic quan trọng: Gán điểm cuối chặng này nối với điểm đầu chặng sau
|
{legIdx === 0 && !leg.locations.some((loc: any) => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
|
||||||
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
|
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
|
||||||
const distanceToNext = nextLocation
|
<div className="z-10 mt-1.5 mr-4">
|
||||||
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-blue-200 flex items-center justify-center text-blue-400">
|
||||||
: null;
|
<MapPin className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onAddLocation?.(leg.id, true)}
|
||||||
|
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 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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
const averageSpeed = 35; // km/h
|
{/* Nút thêm nhanh "Điểm kết thúc" cho Chặng cuối nếu chưa có */}
|
||||||
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
{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 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 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 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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
{leg.locations.map((location: any) => {
|
||||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
// Tìm vị trí của điểm này trong toàn bộ hành trình
|
||||||
|
const globalIdx = allLocations.findIndex((loc: any) => loc.id === location.id);
|
||||||
|
const prevLocation = globalIdx > 0 ? allLocations[globalIdx - 1] : null;
|
||||||
|
|
||||||
|
const distanceFromPrev = prevLocation
|
||||||
|
? calculateDistance(prevLocation.latitude, prevLocation.longitude, location.latitude, location.longitude)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
|
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
|
||||||
|
|
||||||
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
// Nhận diện điểm mốc dựa trên timestamp đặc biệt (0) thay vì chỉ số mảng
|
||||||
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
const isStartPoint = location.plannedStart && new Date(location.plannedStart).getTime() === 0;
|
||||||
|
const isEndPoint = location.plannedEnd && new Date(location.plannedEnd).getTime() === 0;
|
||||||
|
|
||||||
|
const plannedTimeStr = isStartPoint ? location.plannedEnd : location.plannedStart;
|
||||||
|
const hasValidPlannedTime = plannedTimeStr && new Date(plannedTimeStr).getTime() !== 0;
|
||||||
|
|
||||||
|
const dwellMinutes = (location.plannedStart && location.plannedEnd && !isStartPoint && !isEndPoint)
|
||||||
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={location.id}>
|
<div key={location.id}>
|
||||||
<div className="relative flex group mb-6">
|
<div className="relative flex group mb-6">
|
||||||
{/* Timeline Node */}
|
{/* 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
|
<button
|
||||||
onClick={() => toggleComplete(location.id)}
|
onClick={() => handleStatusClick(location)}
|
||||||
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
||||||
>
|
>
|
||||||
{location.status === 'COMPLETED' ? (
|
{location.status === 'COMPLETED' ? (
|
||||||
@@ -276,9 +455,12 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card Content */}
|
{/* Card Content */}
|
||||||
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
|
<div
|
||||||
|
onClick={() => onNavigate?.(location)}
|
||||||
|
className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 cursor-pointer ${
|
||||||
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
|
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
{isStartPoint && (
|
{isStartPoint && (
|
||||||
@@ -287,7 +469,13 @@ export const ItineraryTimeline = ({
|
|||||||
{isEndPoint && (
|
{isEndPoint && (
|
||||||
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
||||||
)}
|
)}
|
||||||
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
<h3
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNavigate?.(location);
|
||||||
|
}}
|
||||||
|
className={`font-semibold text-lg cursor-pointer hover:text-blue-600 transition-colors select-none ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}
|
||||||
|
>
|
||||||
{location.name}
|
{location.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center text-sm text-gray-500 mt-1">
|
<div className="flex items-center text-sm text-gray-500 mt-1">
|
||||||
@@ -309,7 +497,7 @@ export const ItineraryTimeline = ({
|
|||||||
<div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1">
|
<div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1">
|
||||||
<div className="flex items-center gap-1 font-bold">
|
<div className="flex items-center gap-1 font-bold">
|
||||||
<Zap className="w-3 h-3" />
|
<Zap className="w-3 h-3" />
|
||||||
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({locationExpense.category})</span>
|
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ</span>
|
||||||
</div>
|
</div>
|
||||||
{locationExpense.description && (
|
{locationExpense.description && (
|
||||||
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
|
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
|
||||||
@@ -324,22 +512,70 @@ export const ItineraryTimeline = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right flex flex-col items-end">
|
<div className="text-right flex flex-col items-end" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center text-sm font-medium text-blue-600">
|
<div className="flex gap-1 mb-2">
|
||||||
|
{onQuickNote && !isPublicView && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onQuickNote({ legId: leg.id, location, leg });
|
||||||
|
}}
|
||||||
|
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-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCommentLocationId(location.id);
|
||||||
|
setCommentLocationName(location.name);
|
||||||
|
setIsCommentModalOpen(true);
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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" />
|
<Clock className="w-3 h-3 mr-1" />
|
||||||
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
|
{hasValidPlannedTime ? format(parseISO(plannedTimeStr), 'HH:mm') : '--:--'}
|
||||||
</div>
|
</div>
|
||||||
{location.status === 'COMPLETED' && location.actualStart && (
|
{location.status === 'COMPLETED' && location.actualStart && (
|
||||||
<div className="text-[10px] text-gray-400 mt-1 italic">
|
<div className="text-[10px] text-gray-400 mt-1 italic">
|
||||||
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (
|
{canEdit && ( // Allow editing and deleting of all locations if user has edit permissions
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="flex gap-1 mt-2">
|
||||||
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEditLocation?.(location);
|
||||||
|
}}
|
||||||
|
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleDeleteLocation(location.id)} className="p-1 text-gray-400 hover:text-red-600 transition-colors">
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteLocation(location.id);
|
||||||
|
}}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
|
||||||
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,22 +584,17 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logic tính toán độ lệch thời gian */}
|
{/* Logic tính toán độ lệch thời gian */}
|
||||||
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
|
<TimeVariance planned={hasValidPlannedTime ? plannedTimeStr : ''} actual={location.actualStart || null} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{distanceToNext !== null && travelTimeMinutes !== null && (
|
{/* Hiển thị quãng đường di chuyển từ điểm trước ĐẾN điểm hiện tại */}
|
||||||
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3">
|
{distanceFromPrev !== null && distanceFromPrev > 0 && (
|
||||||
<div className="w-8 flex justify-center">
|
<div className="ml-14 -mt-4 mb-6 flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-500">
|
||||||
<Navigation className="w-3 h-3 text-blue-400 rotate-180" />
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-xl border border-blue-100 shadow-sm">
|
||||||
</div>
|
<Navigation className="w-3 h-3 rotate-45" />
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-[10px] font-black uppercase tracking-tighter">
|
||||||
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100">
|
+{distanceFromPrev.toFixed(1)} km từ {prevLocation?.name.split(',')[0]}
|
||||||
{distanceToNext.toFixed(2)} km
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1">
|
|
||||||
<Clock className="w-2.5 h-2.5" />
|
|
||||||
~ {formatTravelTime(travelTimeMinutes)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,66 +602,61 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div> {/* End of ml-2 wrapper */}
|
||||||
</div>
|
</div> {/* End of child-nodes-list */}
|
||||||
|
</div> {/* End of folder-child-content-box */}
|
||||||
|
</div> {/* End of child-nodes-list-wrapper */}
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions at the bottom of the list */}
|
{/* Actions at the bottom of the list */}
|
||||||
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
{canEdit && ( // Only show these buttons if canEdit
|
||||||
<div className="flex flex-col gap-3 pb-20 mt-8">
|
<div className="flex flex-col gap-3 pb-20 mt-8">
|
||||||
<button
|
<button
|
||||||
onClick={handleDeclareLegs}
|
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" />
|
<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"}
|
{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>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddLeg}
|
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
|
<Plus className="w-5 h-5" /> Thêm chặng lẻ vào cuối
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={confirmState.open}
|
|
||||||
title={confirmState.title}
|
|
||||||
message={confirmState.message}
|
|
||||||
onConfirm={() => confirmState.onConfirm?.()}
|
|
||||||
onCancel={() => setConfirmState({ open: false })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal Khai báo số chặng (Popover) */}
|
{/* Modal Khai báo số chặng (Popover) */}
|
||||||
{isLegCountModalOpen && (
|
{isLegCountModalOpen && (
|
||||||
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
<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="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 rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
<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">
|
<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>
|
<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 rounded-full transition-colors">
|
<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" />
|
<X className="w-5 h-5 text-gray-400 dark:text-slate-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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)
|
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>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-6 mb-8">
|
<div className="flex items-center justify-center gap-6 mb-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => setTempLegCount(Math.max(1, tempLegCount - 1))}
|
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>
|
</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
|
<button
|
||||||
onClick={() => setTempLegCount(Math.min(20, tempLegCount + 1))}
|
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>
|
</button>
|
||||||
@@ -438,7 +664,7 @@ export const ItineraryTimeline = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={confirmDeclareLegs}
|
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
|
Xác nhận
|
||||||
</button>
|
</button>
|
||||||
@@ -449,58 +675,58 @@ export const ItineraryTimeline = ({
|
|||||||
{/* Modal Chỉnh sửa Chặng (Popover) */}
|
{/* Modal Chỉnh sửa Chặng (Popover) */}
|
||||||
{isEditModalOpen && (
|
{isEditModalOpen && (
|
||||||
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
<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="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 rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
<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">
|
<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>
|
<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 rounded-full transition-colors">
|
<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" />
|
<X className="w-5 h-5 text-gray-400 dark:text-slate-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editingLegData.note}
|
value={editingLegData.note}
|
||||||
onChange={(e) => setEditingLegData({ ...editingLegData, note: e.target.value })}
|
onChange={(e) => setEditingLegData({ ...editingLegData, note: e.target.value })}
|
||||||
placeholder="VD: Ngày 1: Khởi hành"
|
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>
|
||||||
|
|
||||||
<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
|
<AlignLeft className="w-3 h-3" /> Mô tả chi tiết
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={editingLegData.description}
|
value={editingLegData.description}
|
||||||
onChange={(e) => setEditingLegData({ ...editingLegData, description: e.target.value })}
|
onChange={(e) => setEditingLegData({ ...editingLegData, description: e.target.value })}
|
||||||
placeholder="Mô tả các hoạt động chính trong chặng này..."
|
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<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">
|
||||||
<CalendarIcon className="w-3 h-3" /> Bắt đầu
|
<CalendarIcon className="w-3 h-3" /> Bắt đầu
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={editingLegData.startDate}
|
value={editingLegData.startDate}
|
||||||
onChange={(e) => setEditingLegData({ ...editingLegData, startDate: e.target.value })}
|
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>
|
||||||
<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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={editingLegData.endDate}
|
value={editingLegData.endDate}
|
||||||
onChange={(e) => setEditingLegData({ ...editingLegData, endDate: e.target.value })}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -509,13 +735,13 @@ export const ItineraryTimeline = ({
|
|||||||
<div className="grid grid-cols-2 gap-3 mt-8">
|
<div className="grid grid-cols-2 gap-3 mt-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditModalOpen(false)}
|
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
|
Hủy
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={saveLegEdit}
|
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
|
Lưu thay đổi
|
||||||
</button>
|
</button>
|
||||||
@@ -523,6 +749,15 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<CommentModal
|
||||||
|
isOpen={isCommentModalOpen}
|
||||||
|
onClose={() => setIsCommentModalOpen(false)}
|
||||||
|
locationId={commentLocationId}
|
||||||
|
locationName={commentLocationName}
|
||||||
|
isPublicView={isPublicView}
|
||||||
|
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||||
|
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { X, Mail, Lock, ArrowRight, Loader2, LogIn } from 'lucide-react';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
|
|
||||||
|
interface JoinTourLoginModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
inviteToken: string;
|
||||||
|
onJoinSuccess?: (user: any, tourData: any) => void;
|
||||||
|
onSwitchToSignup?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
inviteToken,
|
||||||
|
onJoinSuccess,
|
||||||
|
onSwitchToSignup
|
||||||
|
}) => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const notify = useNotification();
|
||||||
|
|
||||||
|
// Ref to store the latest inviteToken to avoid stale closure issue
|
||||||
|
const inviteTokenRef = useRef(inviteToken);
|
||||||
|
useEffect(() => {
|
||||||
|
inviteTokenRef.current = inviteToken;
|
||||||
|
}, [inviteToken]);
|
||||||
|
|
||||||
|
const handleGoogleLogin = async (googleResponse: any) => {
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/auth/google`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ credential: googleResponse.credential }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Đăng nhập Google thất bại');
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('token', data.access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
|
// Use the latest inviteToken from ref
|
||||||
|
let currentToken = inviteTokenRef.current;
|
||||||
|
console.log('[JoinTourLogin] Attempting to join with token:', currentToken?.substring(0, 10) + '...');
|
||||||
|
|
||||||
|
// Also check pendingInviteToken as fallback
|
||||||
|
let pendingToken = localStorage.getItem('pendingInviteToken');
|
||||||
|
|
||||||
|
// If no token found, try to get from URL
|
||||||
|
if (!currentToken && !pendingToken) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const urlToken = params.get('token');
|
||||||
|
if (urlToken) {
|
||||||
|
currentToken = urlToken;
|
||||||
|
localStorage.setItem('pendingInviteToken', urlToken);
|
||||||
|
console.log('[JoinTourLogin] Using token from URL params:', urlToken.substring(0, 10) + '...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenToUse = currentToken || pendingToken;
|
||||||
|
|
||||||
|
if (!tokenToUse) {
|
||||||
|
console.error('[JoinTourLogin] No invite token available');
|
||||||
|
throw new Error('Không có mã lời mời để tham gia tour');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${data.access_token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token: tokenToUse }),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[JoinTourLogin] Join response status:', joinRes.status);
|
||||||
|
const joinData = await joinRes.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (joinRes.ok) {
|
||||||
|
console.log('[JoinTourLogin] Join successful');
|
||||||
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: joinData.message || 'Bạn đã gia nhập tour!',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
localStorage.removeItem('pendingInviteToken');
|
||||||
|
if (onJoinSuccess) {
|
||||||
|
onJoinSuccess(data.user, joinData);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// Token invalid or expired - clear it and show error
|
||||||
|
console.log('[JoinTourLogin] Token invalid, clearing from storage');
|
||||||
|
localStorage.removeItem('pendingInviteToken');
|
||||||
|
// Email mismatch or other error
|
||||||
|
const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`;
|
||||||
|
console.error('[JoinTourLogin] Join failed:', errorMessage);
|
||||||
|
setError(`Lỗi gia nhập tour: ${errorMessage}`);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[JoinTourLogin] Join exception:', e);
|
||||||
|
setError(`Lỗi khi gia nhập: ${e.message}`);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[JoinTourLogin] Google login error:', err);
|
||||||
|
setError(err.message);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailPasswordJoin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Đăng nhập thất bại');
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('token', data.access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
|
// Use the latest inviteToken from ref
|
||||||
|
const tokenToUse = inviteTokenRef.current || localStorage.getItem('pendingInviteToken');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${data.access_token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token: tokenToUse }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const joinData = await joinRes.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (joinRes.ok) {
|
||||||
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: joinData.message || 'Bạn đã gia nhập tour!',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
localStorage.removeItem('pendingInviteToken');
|
||||||
|
if (onJoinSuccess) {
|
||||||
|
onJoinSuccess(data.user, joinData);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
const errorMessage = joinData.message || 'Không thể gia nhập tour';
|
||||||
|
setError(`Lỗi gia nhập tour: ${errorMessage}`);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(`Lỗi khi gia nhập: ${e.message}`);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[JoinTourLogin] Login error:', err);
|
||||||
|
setError(err.message);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (typeof window !== 'undefined' && (window as any).google) {
|
||||||
|
try {
|
||||||
|
(window as any).google.accounts.id.initialize({
|
||||||
|
client_id: (import.meta as any).env.VITE_GOOGLE_CLIENT_ID || '864264639911-dummyid.apps.googleusercontent.com',
|
||||||
|
callback: handleGoogleLogin,
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).google.accounts.id.renderButton(
|
||||||
|
document.getElementById('google-signin-btn-join-tour'),
|
||||||
|
{ theme: 'outline', size: 'large', width: '380' }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Lỗi khởi tạo Google Sign-in:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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-[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">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_50%,rgba(255,255,255,.3)_0%,transparent_50%)]" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 z-10 p-2 hover:bg-white/20 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<LogIn className="w-12 h-12 text-white opacity-80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-8">
|
||||||
|
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-2 text-center">
|
||||||
|
Gia nhập tour
|
||||||
|
</h2>
|
||||||
|
<p className="text-center text-[var(--text-secondary)] mb-6">
|
||||||
|
Đăng nhập để tham gia chuyến du lịch này
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Google OAuth Button */}
|
||||||
|
<div className="mb-6 flex justify-center">
|
||||||
|
<div id="google-signin-btn-join-tour" className="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-[var(--border)]" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<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-[var(--text-secondary)] mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<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-[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-[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-[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-[var(--border)] rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition bg-[var(--background)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-gradient-to-r from-blue-500 to-purple-500 text-white font-semibold py-3 rounded-lg hover:shadow-lg transition-all disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Đang gia nhập...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
Gia nhập tour
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Signup Link */}
|
||||||
|
<div className="mt-6 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
Chưa có tài khoản?{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
if (onSwitchToSignup) onSwitchToSignup();
|
||||||
|
}}
|
||||||
|
className="font-semibold text-blue-500 hover:text-blue-600 transition"
|
||||||
|
>
|
||||||
|
Đăng ký tại đây
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface LoginModalProps {
|
interface LoginModalProps {
|
||||||
@@ -14,6 +14,117 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleGoogleLogin = async (googleResponse: any) => {
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/auth/google`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ credential: googleResponse.credential }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Đăng nhập Google thất bại');
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('token', data.access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
// Remove guest tokens to ensure clean real user session
|
||||||
|
localStorage.removeItem('guest_token');
|
||||||
|
localStorage.removeItem('guest_user');
|
||||||
|
console.log('[OAuth] Google login successful');
|
||||||
|
|
||||||
|
// Check if there's a pending invite token to join tour
|
||||||
|
// Try localStorage first, then fallback to URL parameter
|
||||||
|
let pendingInviteToken = localStorage.getItem('pendingInviteToken');
|
||||||
|
console.log('[OAuth] pendingInviteToken from localStorage:', pendingInviteToken ? pendingInviteToken.substring(0, 20) + '...' : 'none');
|
||||||
|
|
||||||
|
// If no token in localStorage, try to get from URL (in case of race condition)
|
||||||
|
if (!pendingInviteToken) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const urlToken = params.get('token');
|
||||||
|
if (urlToken) {
|
||||||
|
pendingInviteToken = urlToken;
|
||||||
|
localStorage.setItem('pendingInviteToken', urlToken);
|
||||||
|
console.log('[OAuth] Using token from URL params:', urlToken.substring(0, 20) + '...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingInviteToken) {
|
||||||
|
try {
|
||||||
|
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${data.access_token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token: pendingInviteToken }),
|
||||||
|
});
|
||||||
|
const joinData = await joinRes.json().catch(() => ({}));
|
||||||
|
if (joinRes.ok) {
|
||||||
|
localStorage.removeItem('pendingInviteToken');
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(data.user);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// If invitation is invalid/expired, clear it and redirect to dashboard
|
||||||
|
console.log('[OAuth] Token invalid or expired, clearing and redirecting to dashboard');
|
||||||
|
localStorage.removeItem('pendingInviteToken');
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(data.user);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (joinErr: any) {
|
||||||
|
console.error('[OAuth] Join tour after login failed:', joinErr);
|
||||||
|
localStorage.removeItem('pendingInviteToken');
|
||||||
|
// Still redirect to dashboard on error
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(data.user);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular login - no auto-join for tour
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(data.user);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[OAuth] Login error:', err);
|
||||||
|
setError(err.message);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (typeof window !== 'undefined' && (window as any).google) {
|
||||||
|
try {
|
||||||
|
(window as any).google.accounts.id.initialize({
|
||||||
|
client_id: (import.meta as any).env.VITE_GOOGLE_CLIENT_ID || '864264639911-dummyid.apps.googleusercontent.com',
|
||||||
|
callback: handleGoogleLogin,
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).google.accounts.id.renderButton(
|
||||||
|
document.getElementById('google-signin-btn-login'),
|
||||||
|
{ theme: 'outline', size: 'large', width: '380' }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Lỗi khởi tạo Google Sign-in:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -36,7 +147,11 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
|||||||
// Lưu phiên đăng nhập
|
// Lưu phiên đăng nhập
|
||||||
localStorage.setItem('token', data.access_token);
|
localStorage.setItem('token', data.access_token);
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
localStorage.removeItem('guest_token');
|
||||||
|
localStorage.removeItem('guest_user');
|
||||||
|
console.log('[LoginModal] Email/password login successful');
|
||||||
|
|
||||||
|
// Regular login - no auto-join for tour
|
||||||
if (onLoginSuccess) {
|
if (onLoginSuccess) {
|
||||||
onLoginSuccess(data.user);
|
onLoginSuccess(data.user);
|
||||||
}
|
}
|
||||||
@@ -57,16 +172,16 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal Content */}
|
{/* Modal Content */}
|
||||||
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
<div className="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="p-8 sm:p-10">
|
||||||
<div className="flex justify-between items-start mb-8">
|
<div className="flex justify-between items-start mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold text-gray-900">Đăng nhập</h2>
|
<h2 className="text-3xl font-bold text-[var(--text-primary)]">Đăng nhập</h2>
|
||||||
<p className="text-gray-500 mt-2">Chào mừng bạn quay trở lại!</p>
|
<p className="text-[var(--text-secondary)] mt-2">Chào mừng bạn quay trở lại!</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -80,34 +195,34 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
|||||||
|
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-gray-700 ml-1">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">
|
<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
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
placeholder="name@example.com"
|
placeholder="admin hoặc email..."
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center px-1">
|
<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>
|
<button className="text-xs font-bold text-blue-600 hover:text-blue-700">Quên mật khẩu?</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative group">
|
<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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
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>
|
</div>
|
||||||
@@ -122,8 +237,17 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-10 pt-8 border-t border-gray-100 text-center">
|
<div className="relative my-6 flex items-center justify-center">
|
||||||
<p className="text-gray-500">
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-[var(--border)]"></div>
|
||||||
|
</div>
|
||||||
|
<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-[var(--border)] text-center">
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
Chưa có tài khoản?{' '}
|
Chưa có tài khoản?{' '}
|
||||||
<button
|
<button
|
||||||
onClick={() => { onClose(); onSwitchToSignup?.(); }}
|
onClick={() => { onClose(); onSwitchToSignup?.(); }}
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Users, User, Shield, ShieldAlert, ShieldCheck, Mail, Trash2,
|
||||||
|
Clock, Check, X, GitMerge, ArrowRight, Search, Plus, Sparkles
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
|
|
||||||
|
interface MembersTabProps {
|
||||||
|
tourId: string;
|
||||||
|
participants: any[];
|
||||||
|
joinRequests: any[];
|
||||||
|
userRole: string | null;
|
||||||
|
canManage: boolean;
|
||||||
|
isOwner: boolean;
|
||||||
|
onRemoveMember: (memberIdOrUserId: string) => Promise<void>;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onOpenAddMember?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MembersTab: React.FC<MembersTabProps> = ({
|
||||||
|
tourId,
|
||||||
|
participants,
|
||||||
|
joinRequests: initialJoinRequests,
|
||||||
|
canManage,
|
||||||
|
isOwner,
|
||||||
|
onRemoveMember,
|
||||||
|
onRefresh,
|
||||||
|
onOpenAddMember
|
||||||
|
}) => {
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotification();
|
||||||
|
|
||||||
|
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
|
||||||
|
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
||||||
|
|
||||||
|
const [joinRequests, setJoinRequests] = useState<any[]>(initialJoinRequests);
|
||||||
|
const [mergingId, setMergingId] = useState<string | null>(null);
|
||||||
|
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
||||||
|
const [assigningManualMember, setAssigningManualMember] = useState<any | null>(null);
|
||||||
|
const [systemSearchQuery, setSystemSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Sync state with props
|
||||||
|
React.useEffect(() => {
|
||||||
|
setJoinRequests(initialJoinRequests);
|
||||||
|
}, [initialJoinRequests]);
|
||||||
|
|
||||||
|
// Separate system vs manual members
|
||||||
|
const systemMembers = useMemo(() => {
|
||||||
|
return participants.filter((p: any) => p.userId && p.user);
|
||||||
|
}, [participants]);
|
||||||
|
|
||||||
|
const manualMembers = useMemo(() => {
|
||||||
|
return participants.filter((p: any) => !p.userId && p.displayName);
|
||||||
|
}, [participants]);
|
||||||
|
|
||||||
|
// Auto-detect duplicate matches based on case-insensitive names
|
||||||
|
const duplicateMatches = useMemo(() => {
|
||||||
|
const matches: Array<{ manual: any; system: any }> = [];
|
||||||
|
manualMembers.forEach((m: any) => {
|
||||||
|
const match = systemMembers.find((s: any) => {
|
||||||
|
return s.user.name.trim().toLowerCase() === m.displayName.trim().toLowerCase();
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
matches.push({ manual: m, system: match });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return matches;
|
||||||
|
}, [systemMembers, manualMembers]);
|
||||||
|
|
||||||
|
// Filter system members for manual merge modal
|
||||||
|
const filteredSystemMembersForMerge = useMemo(() => {
|
||||||
|
if (!systemSearchQuery.trim()) return systemMembers;
|
||||||
|
return systemMembers.filter((s: any) =>
|
||||||
|
s.user.name.toLowerCase().includes(systemSearchQuery.toLowerCase()) ||
|
||||||
|
(s.user.email && s.user.email.toLowerCase().includes(systemSearchQuery.toLowerCase()))
|
||||||
|
);
|
||||||
|
}, [systemMembers, systemSearchQuery]);
|
||||||
|
|
||||||
|
// Handle merging logic
|
||||||
|
const handleMerge = async (manualParticipantId: string, systemUserId: string, manualName: string, systemName: string) => {
|
||||||
|
const isConfirmed = await confirm({
|
||||||
|
title: 'Hợp nhất thành viên',
|
||||||
|
message: `Bạn có chắc muốn hợp nhất thành viên thủ công "${manualName}" vào tài khoản "${systemName}"? Bản ghi thủ công sẽ bị xóa và các dữ liệu liên quan sẽ được gộp.`
|
||||||
|
});
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
|
||||||
|
setMergingId(manualParticipantId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/tours/${tourId}/members/merge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
manualParticipantId,
|
||||||
|
systemUserId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || 'Hợp nhất thất bại.');
|
||||||
|
}
|
||||||
|
notify({ title: 'Thành công', message: 'Hợp nhất thành viên thành công!', type: 'success' });
|
||||||
|
setAssigningManualMember(null);
|
||||||
|
onRefresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
notify({ title: 'Lỗi', message: e.message || 'Không thể hợp nhất', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setMergingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleBadge = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'OWNER':
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-red-50 text-red-700 border border-red-100 text-[10px] font-bold">
|
||||||
|
<ShieldAlert className="w-3 h-3 text-red-500" /> Trưởng đoàn
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'MANAGER':
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-blue-50 text-blue-700 border border-blue-100 text-[10px] font-bold">
|
||||||
|
<ShieldCheck className="w-3 h-3 text-blue-500" /> Phó đoàn
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'MEMBER':
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-green-50 text-green-700 border border-green-100 text-[10px] font-bold">
|
||||||
|
<Shield className="w-3 h-3 text-green-500" /> Thành viên
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'MEMBER_NO_FINANCE':
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-gray-50 text-gray-600 border border-gray-100 text-[10px] font-bold">
|
||||||
|
<Shield className="w-3 h-3 text-gray-400" /> Thành viên (Không xem quỹ)
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-gray-100 text-gray-600 text-[10px] font-semibold">
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current user id to prevent self-deletion
|
||||||
|
const getCurrentUserId = () => {
|
||||||
|
const rawToken = localStorage.getItem('token');
|
||||||
|
if (!rawToken) return null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(rawToken.split('.')[1]));
|
||||||
|
return payload.sub;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const currentUserId = getCurrentUserId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header and Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-white p-5 rounded-3xl border border-gray-100 shadow-sm animate-in fade-in duration-300">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<Users className="w-6 h-6 text-indigo-600" /> Quản lý thành viên
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Quản lý thành viên hệ thống, thành viên thủ công và các yêu cầu tham gia chuyến đi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canManage && onOpenAddMember && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenAddMember}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2.5 rounded-2xl bg-indigo-600 hover:bg-indigo-700 active:scale-95 text-white font-bold text-xs transition-all shadow-md shadow-indigo-100"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Thêm & Mời thành viên
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-detect duplicates alert */}
|
||||||
|
{duplicateMatches.length > 0 && (
|
||||||
|
<div className="p-5 bg-amber-50 rounded-3xl border border-amber-200/60 text-amber-900 space-y-3 shadow-sm animate-in slide-in-from-top-4 duration-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-amber-500 animate-pulse" />
|
||||||
|
<h4 className="font-bold text-sm">Phát hiện trùng lặp tự động</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-amber-700">
|
||||||
|
Hệ thống phát hiện có thành viên được tạo thủ công trùng tên với tài khoản hệ thống mới gia nhập. Bạn nên gộp họ lại để đồng bộ thông tin chặng đi và chi phí.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{duplicateMatches.map((match) => (
|
||||||
|
<div
|
||||||
|
key={match.manual.id}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-white rounded-2xl border border-amber-200 shadow-sm text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-bold text-gray-800">Thành viên thủ công: "{match.manual.displayName}"</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5 text-amber-500" />
|
||||||
|
<span className="font-bold text-indigo-700">Tài khoản hệ thống: "{match.system.user.name}"</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={mergingId === match.manual.id}
|
||||||
|
onClick={() => handleMerge(match.manual.id, match.system.userId, match.manual.displayName, match.system.user.name)}
|
||||||
|
className="px-3.5 py-1.5 bg-amber-500 hover:bg-amber-600 disabled:bg-amber-300 text-white font-bold rounded-xl transition-all text-[11px] self-end sm:self-auto flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<GitMerge className="w-3.5 h-3.5" />
|
||||||
|
{mergingId === match.manual.id ? 'Đang xử lý...' : 'Gán & Hợp nhất'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending requests */}
|
||||||
|
{joinRequests.length > 0 && (
|
||||||
|
<div className="p-5 bg-white rounded-3xl border border-gray-100 shadow-sm space-y-4 animate-in fade-in duration-300">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="w-5 h-5 text-indigo-500 animate-pulse" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-900">Yêu cầu tham gia chờ duyệt</h3>
|
||||||
|
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">{joinRequests.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{joinRequests.map((req: any) => (
|
||||||
|
<div key={req.id} className="flex items-center justify-between gap-3 p-3.5 rounded-2xl border border-gray-100 bg-gray-50/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-indigo-50 border border-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-sm">
|
||||||
|
{req.user?.name?.charAt(0) || '?'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-gray-800">{req.user?.name || req.userId}</div>
|
||||||
|
<div className="text-[10px] text-gray-500 mt-0.5">
|
||||||
|
{req.user?.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isOwner && (
|
||||||
|
<div className="flex gap-1.5 shrink-0">
|
||||||
|
<button
|
||||||
|
disabled={joinRequestActionId === req.id}
|
||||||
|
onClick={async () => {
|
||||||
|
const isConfirmed = await confirm({
|
||||||
|
title: 'Chấp nhận yêu cầu',
|
||||||
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
|
||||||
|
});
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await acceptJoinRequest(tourId, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
onRefresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
notify({ title: 'Lỗi', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-xl bg-green-50 hover:bg-green-100 text-green-700 transition-colors disabled:opacity-50"
|
||||||
|
title="Chấp nhận"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={joinRequestActionId === req.id}
|
||||||
|
onClick={async () => {
|
||||||
|
const isConfirmed = await confirm({
|
||||||
|
title: 'Từ chối yêu cầu',
|
||||||
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
|
||||||
|
});
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await rejectJoinRequest(tourId, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
onRefresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
notify({ title: 'Lỗi', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-xl bg-red-50 hover:bg-red-100 text-red-700 transition-colors disabled:opacity-50"
|
||||||
|
title="Từ chối"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Members Grid */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* System Accounts */}
|
||||||
|
<div className="p-5 bg-white rounded-3xl border border-gray-100 shadow-sm space-y-4 flex flex-col">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||||
|
Thành viên hệ thống ({systemMembers.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5 flex-1 max-h-[400px] overflow-y-auto pr-1">
|
||||||
|
{systemMembers.map((member: any) => {
|
||||||
|
const isCurrentUser = currentUserId && member.userId === currentUserId;
|
||||||
|
const isMemberOwner = member.role === 'OWNER';
|
||||||
|
const canRemove = canManage && !isCurrentUser && !isMemberOwner;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={member.id} className="flex items-center justify-between p-3 bg-gray-50/50 rounded-2xl border border-gray-100/50 hover:border-gray-200 transition-all">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-indigo-50 border border-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-sm">
|
||||||
|
{member.user.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-gray-800 flex items-center gap-1.5">
|
||||||
|
{member.user.name}
|
||||||
|
{isCurrentUser && <span className="text-[9px] bg-indigo-100 text-indigo-700 font-black px-1.5 py-0.5 rounded-md">Tôi</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-500 flex items-center gap-1 mt-0.5">
|
||||||
|
<Mail className="w-3 h-3 text-gray-400" />
|
||||||
|
{member.user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getRoleBadge(member.role)}
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const isConfirmed = await confirm({
|
||||||
|
title: 'Xóa thành viên',
|
||||||
|
message: `Bạn có chắc chắn muốn xóa thành viên "${member.user.name}" khỏi hành trình?`
|
||||||
|
});
|
||||||
|
if (isConfirmed) {
|
||||||
|
try {
|
||||||
|
await onRemoveMember(member.userId);
|
||||||
|
notify({ title: 'Thành công', message: 'Đã xóa thành viên', type: 'success' });
|
||||||
|
} catch (e: any) {
|
||||||
|
notify({ title: 'Lỗi', message: e.message || 'Không thể xóa thành viên', type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded-xl transition-all"
|
||||||
|
title="Xóa thành viên"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Members */}
|
||||||
|
<div className="p-5 bg-white rounded-3xl border border-gray-100 shadow-sm space-y-4 flex flex-col">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-500"></span>
|
||||||
|
Thành viên thủ công ({manualMembers.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5 flex-1 max-h-[400px] overflow-y-auto pr-1">
|
||||||
|
{manualMembers.map((member: any) => {
|
||||||
|
const canRemove = canManage;
|
||||||
|
return (
|
||||||
|
<div key={member.id} className="flex items-center justify-between p-3 bg-gray-50/50 rounded-2xl border border-gray-100/50 hover:border-gray-200 transition-all">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-amber-50 border border-amber-100 flex items-center justify-center text-amber-700 font-bold text-sm">
|
||||||
|
{member.displayName.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-gray-800">
|
||||||
|
{member.displayName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-400 mt-0.5">
|
||||||
|
Tạo ngoài hệ thống
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getRoleBadge(member.role)}
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
onClick={() => setAssigningManualMember(member)}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1 rounded-xl bg-indigo-50 hover:bg-indigo-100 text-indigo-700 text-[10px] font-bold transition-all border border-indigo-100"
|
||||||
|
title="Hợp nhất với tài khoản hệ thống"
|
||||||
|
>
|
||||||
|
<GitMerge className="w-3 h-3" />
|
||||||
|
Gán tài khoản
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const isConfirmed = await confirm({
|
||||||
|
title: 'Xóa thành viên',
|
||||||
|
message: `Bạn có chắc chắn muốn xóa thành viên thủ công "${member.displayName}"?`
|
||||||
|
});
|
||||||
|
if (isConfirmed) {
|
||||||
|
try {
|
||||||
|
await onRemoveMember(member.id);
|
||||||
|
notify({ title: 'Thành công', message: 'Đã xóa thành viên', type: 'success' });
|
||||||
|
} catch (e: any) {
|
||||||
|
notify({ title: 'Lỗi', message: e.message || 'Không thể xóa thành viên', type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded-xl transition-all"
|
||||||
|
title="Xóa thành viên"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{manualMembers.length === 0 && (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center py-10 text-gray-400">
|
||||||
|
<User className="w-8 h-8 opacity-40 mb-2" />
|
||||||
|
<span className="text-xs">Chưa có thành viên thủ công nào</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Merge Modal Selector */}
|
||||||
|
{assigningManualMember && (
|
||||||
|
<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={() => setAssigningManualMember(null)} />
|
||||||
|
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<GitMerge className="w-5 h-5 text-indigo-600" /> Gán tài khoản hệ thống
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Chọn một tài khoản hệ thống để gán cho thành viên thủ công <strong>"{assigningManualMember.displayName}"</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setAssigningManualMember(null)}
|
||||||
|
className="p-1.5 hover:bg-gray-100 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="p-4 border-b border-gray-100">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tìm tài khoản hệ thống theo tên hoặc email..."
|
||||||
|
value={systemSearchQuery}
|
||||||
|
onChange={(e) => setSystemSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-xl text-xs outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System accounts list */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
|
{filteredSystemMembersForMerge.map((systemMember: any) => (
|
||||||
|
<button
|
||||||
|
key={systemMember.id}
|
||||||
|
disabled={mergingId === assigningManualMember.id}
|
||||||
|
onClick={() => handleMerge(assigningManualMember.id, systemMember.userId, assigningManualMember.displayName, systemMember.user.name)}
|
||||||
|
className="w-full flex items-center justify-between p-3 hover:bg-indigo-50/50 active:bg-indigo-50 border border-gray-100 hover:border-indigo-100 rounded-2xl text-left transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-indigo-50 border border-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-xs shrink-0">
|
||||||
|
{systemMember.user.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-bold text-gray-800 truncate">{systemMember.user.name}</div>
|
||||||
|
<div className="text-[10px] text-gray-500 truncate mt-0.5">{systemMember.user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<ArrowRight className="w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filteredSystemMembersForMerge.length === 0 && (
|
||||||
|
<div className="py-10 text-center text-gray-400 text-xs">
|
||||||
|
Không tìm thấy tài khoản hệ thống phù hợp.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
import { CheckCircle, AlertCircle, Info } from 'lucide-react';
|
||||||
|
|
||||||
interface NotificationModalProps {
|
interface NotificationModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -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} />
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onConfirm} />
|
||||||
|
|
||||||
{/* Modal Content */}
|
{/* 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">
|
<div className="flex justify-center mb-5">
|
||||||
{icons[type]}
|
{icons[type]}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-xl font-black text-gray-900 mb-2">{title}</h2>
|
<h2 className="text-xl font-black text-[var(--text-primary)] mb-2">{title}</h2>
|
||||||
<p className="text-gray-500 text-sm leading-relaxed mb-8">
|
<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!"}
|
{message || "Bạn không được phép gỡ bỏ thành viên này!"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,844 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart, Trash2 } from 'lucide-react';
|
||||||
|
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;
|
||||||
|
userName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublicPhotoModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
photo: {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
capturedAt: string;
|
||||||
|
metadata?: {
|
||||||
|
lat?: number;
|
||||||
|
lng?: number;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
uploader?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
uploaderId?: string;
|
||||||
|
};
|
||||||
|
photoGroup?: any[];
|
||||||
|
onSelectPhoto?: (photo: any) => void;
|
||||||
|
onLoginSuccess?: (user: any) => void;
|
||||||
|
onUpdatePhoto?: (updatedPhoto: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
photo,
|
||||||
|
photoGroup = [],
|
||||||
|
onSelectPhoto,
|
||||||
|
onLoginSuccess,
|
||||||
|
onUpdatePhoto
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotification();
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const commentsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editDescription, setEditDescription] = useState('');
|
||||||
|
const [editLat, setEditLat] = useState<number | ''>('');
|
||||||
|
const [editLng, setEditLng] = useState<number | ''>('');
|
||||||
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||||
|
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lat = photo?.metadata?.lat;
|
||||||
|
const lng = photo?.metadata?.lng;
|
||||||
|
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||||
|
setResolvedAddress('Đang xác định địa điểm...');
|
||||||
|
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`)
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data && data.display_name) {
|
||||||
|
const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim();
|
||||||
|
setResolvedAddress(shortAddress || data.display_name);
|
||||||
|
} else {
|
||||||
|
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setResolvedAddress('Chưa xác định tọa độ');
|
||||||
|
}
|
||||||
|
}, [photo?.id, photo?.metadata?.lat, photo?.metadata?.lng]);
|
||||||
|
|
||||||
|
const checkCurrentUser = () => {
|
||||||
|
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
|
||||||
|
if (userStr) {
|
||||||
|
try {
|
||||||
|
setCurrentUser(JSON.parse(userStr));
|
||||||
|
} catch (e) {}
|
||||||
|
} else {
|
||||||
|
setCurrentUser(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkCurrentUser();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (photo) {
|
||||||
|
setEditTitle(photo.metadata?.title || '');
|
||||||
|
setEditDescription(photo.metadata?.description || '');
|
||||||
|
setEditLat(photo.metadata?.lat ?? '');
|
||||||
|
setEditLng(photo.metadata?.lng ?? '');
|
||||||
|
setIsEditing(false); // Reset editing mode when selected photo changes
|
||||||
|
}
|
||||||
|
}, [photo]);
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (editLat !== '' && (isNaN(editLat) || editLat < -90 || editLat > 90)) {
|
||||||
|
alert('Vĩ độ không hợp lệ (-90 đến 90)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editLng !== '' && (isNaN(editLng) || editLng < -180 || editLng > 180)) {
|
||||||
|
alert('Kinh độ không hợp lệ (-180 đến 180)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingEdit(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
||||||
|
const res = await fetch(`/api/v1/photos/${photo.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: editTitle,
|
||||||
|
description: editDescription,
|
||||||
|
latitude: editLat === '' ? undefined : editLat,
|
||||||
|
longitude: editLng === '' ? undefined : editLng
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const updatedPhoto = await res.json();
|
||||||
|
setIsEditing(false);
|
||||||
|
if (onUpdatePhoto) {
|
||||||
|
onUpdatePhoto(updatedPhoto);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.message || 'Lỗi khi cập nhật thông tin ảnh.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi khi cập nhật thông tin ảnh:', error);
|
||||||
|
alert('Không thể kết nối đến máy chủ.');
|
||||||
|
} finally {
|
||||||
|
setIsSavingEdit(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const likedUserIds = photo.metadata && Array.isArray((photo.metadata as any).likedUserIds)
|
||||||
|
? (photo.metadata as any).likedUserIds
|
||||||
|
: [];
|
||||||
|
const isLiked = currentUser && likedUserIds.includes(currentUser.id);
|
||||||
|
const likeCount = likedUserIds.length;
|
||||||
|
|
||||||
|
const handleToggleLike = async () => {
|
||||||
|
let token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
||||||
|
let userObj = currentUser;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
||||||
|
if (guestRes.ok) {
|
||||||
|
const guestData = await guestRes.json();
|
||||||
|
token = guestData.access_token;
|
||||||
|
userObj = guestData.user;
|
||||||
|
localStorage.setItem('token', token!);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userObj));
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(userObj);
|
||||||
|
}
|
||||||
|
setCurrentUser(userObj);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Không thể tạo phiên khách:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/photos/${photo.id}/toggle-like`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const updatedPhoto = await res.json();
|
||||||
|
if (onUpdatePhoto) {
|
||||||
|
onUpdatePhoto(updatedPhoto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi khi thích ảnh:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const fetchComments = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/public-photos/${photo.id}/comments`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setComments(
|
||||||
|
data.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
userName: c.user?.name || 'Ẩn danh',
|
||||||
|
content: c.content,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
userId: c.userId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi khi tải bình luận:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !photo.id) return;
|
||||||
|
|
||||||
|
fetchComments();
|
||||||
|
|
||||||
|
const socket = io();
|
||||||
|
socket.emit('joinPhoto', photo.id);
|
||||||
|
|
||||||
|
socket.on('photoCommentAdded', (newCommentData: any) => {
|
||||||
|
if (newCommentData.photoId === photo.id) {
|
||||||
|
setComments(prev => {
|
||||||
|
if (prev.find(c => c.id === newCommentData.id)) return prev;
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: newCommentData.id,
|
||||||
|
userName: newCommentData.user?.name || 'Ẩn danh',
|
||||||
|
content: newCommentData.content,
|
||||||
|
createdAt: newCommentData.createdAt,
|
||||||
|
userId: newCommentData.userId
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('photoCommentDeleted', (deleted: any) => {
|
||||||
|
if (deleted.photoId === photo.id) {
|
||||||
|
setComments(prev => prev.filter(c => c.id !== deleted.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [isOpen, photo.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!newComment.trim()) return;
|
||||||
|
|
||||||
|
setIsSending(true);
|
||||||
|
try {
|
||||||
|
let token = localStorage.getItem('token');
|
||||||
|
let currentUser = JSON.parse(localStorage.getItem('user') || 'null');
|
||||||
|
|
||||||
|
// Nếu chưa có token, tự động tạo tài khoản khách
|
||||||
|
if (!token) {
|
||||||
|
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
||||||
|
if (!guestRes.ok) throw new Error('Không thể tạo tài khoản khách tự động.');
|
||||||
|
const guestData = await guestRes.json();
|
||||||
|
token = guestData.access_token;
|
||||||
|
currentUser = guestData.user;
|
||||||
|
localStorage.setItem('guest_token', token!);
|
||||||
|
localStorage.setItem('guest_user', JSON.stringify(currentUser));
|
||||||
|
localStorage.setItem('token', token!);
|
||||||
|
localStorage.setItem('user', JSON.stringify(currentUser));
|
||||||
|
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(currentUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await fetch(`/api/v1/public-photos/${photo.id}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: newComment })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
console.warn('Token invalid or expired. Creating a new guest user and retrying comment...');
|
||||||
|
localStorage.removeItem('guest_token');
|
||||||
|
localStorage.removeItem('guest_user');
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
|
||||||
|
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
||||||
|
if (!guestRes.ok) throw new Error('Không thể tạo lại phiên khách.');
|
||||||
|
const guestData = await guestRes.json();
|
||||||
|
token = guestData.access_token;
|
||||||
|
currentUser = guestData.user;
|
||||||
|
localStorage.setItem('guest_token', token!);
|
||||||
|
localStorage.setItem('guest_user', JSON.stringify(currentUser));
|
||||||
|
localStorage.setItem('token', token!);
|
||||||
|
localStorage.setItem('user', JSON.stringify(currentUser));
|
||||||
|
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
res = await fetch(`/api/v1/public-photos/${photo.id}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: newComment })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setNewComment('');
|
||||||
|
fetchComments();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
console.error('Lỗi khi gửi bình luận:', err.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi khi gửi bình luận:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteComment = async (commentId: string) => {
|
||||||
|
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}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
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);
|
||||||
|
notify({ title: 'Lỗi', message: 'Không thể kết nối đến máy chủ.', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAuthorized = currentUser?.isAdmin ||
|
||||||
|
(currentUser && photo.uploader && currentUser.id === photo.uploader.id) ||
|
||||||
|
(currentUser && photo.uploaderId && currentUser.id === photo.uploaderId);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[5000] md:flex md:items-center md:justify-center md:p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Container */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 overflow-y-auto flex flex-col bg-slate-900 text-slate-100 md:relative md:inset-auto md:overflow-hidden md:rounded-[32px] md:shadow-2xl md:border md:border-slate-800 md:w-full md:max-w-5xl md:h-[85vh] md:flex-row animate-in zoom-in-95 duration-300"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Close Button Mobile/Desktop */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="fixed md:absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 md:top-4 md:right-4 z-50 p-2 bg-slate-950/60 hover:bg-slate-800/80 border border-slate-700/50 rounded-full text-slate-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Left Side: Photo Detail */}
|
||||||
|
<div className="relative w-full md:w-3/5 md:h-full bg-slate-950 flex flex-col overflow-hidden group shrink-0">
|
||||||
|
|
||||||
|
{/* Photo wrapper for mobile view (handles top overlay name and bottom-right like) */}
|
||||||
|
<div className="relative w-full flex items-center justify-center md:absolute md:inset-0 md:flex md:items-center md:justify-center bg-slate-950">
|
||||||
|
{/* Mobile Only: Uploader details overlay */}
|
||||||
|
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-4 z-40 md:hidden flex items-center gap-2 bg-slate-950/70 backdrop-blur-md px-2.5 py-1.5 rounded-full border border-slate-700/50">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||||
|
<User className="w-3 h-3 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-200 max-w-[120px] truncate">
|
||||||
|
{photo.uploader?.name || 'Ẩn danh'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Like Button Overlay */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleLike();
|
||||||
|
}}
|
||||||
|
className="absolute bottom-4 right-4 z-40 flex items-center gap-1.5 bg-slate-950/70 hover:bg-slate-900/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md md:absolute md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
|
||||||
|
title={isLiked ? "Bỏ thích" : "Thích"}
|
||||||
|
>
|
||||||
|
<Heart className={`w-4 h-4 transition-colors ${
|
||||||
|
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-slate-350 hover:text-rose-450'
|
||||||
|
}`} />
|
||||||
|
<span>{likeCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
|
||||||
|
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center relative"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsFullscreen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={photo.imageUrl}
|
||||||
|
alt="Public Map Upload"
|
||||||
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
|
className={`w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none ${
|
||||||
|
!isLoggedIn ? 'pointer-events-none' : ''
|
||||||
|
}`}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
{/* Shield overlay to prevent Save Image As on right-click / long press for non-owners */}
|
||||||
|
{(!isAuthorized || !isLoggedIn) && (
|
||||||
|
<div className="absolute inset-0 bg-transparent select-none z-10" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info & Timeline overlay inside photo panel */}
|
||||||
|
<div className="relative z-20 p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* Timeline scroll */}
|
||||||
|
{photoGroup && photoGroup.length > 1 && (
|
||||||
|
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3 relative z-20">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
|
||||||
|
Lịch sử ảnh tại vị trí này ({photoGroup.length})
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1 relative z-20">
|
||||||
|
{photoGroup.map((p) => {
|
||||||
|
const isActive = p.id === photo.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectPhoto?.(p);
|
||||||
|
}}
|
||||||
|
className={`relative w-12 h-12 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
|
||||||
|
isActive ? 'border-emerald-500 scale-110 shadow-lg' : 'border-slate-700 hover:border-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={p.imageUrl}
|
||||||
|
alt="Timeline thumbnail"
|
||||||
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
|
className={`w-full h-full object-cover ${
|
||||||
|
!isLoggedIn ? 'pointer-events-none' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[8px] text-center font-bold text-slate-300 py-0.5">
|
||||||
|
{new Date(p.capturedAt).toLocaleDateString('vi-VN', { month: '2-digit', day: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex flex-col gap-3 bg-slate-900/95 border border-slate-800 p-4 rounded-2xl animate-in slide-in-from-bottom-2">
|
||||||
|
<h4 className="text-xs font-black uppercase tracking-wider text-emerald-400">Chỉnh sửa thông tin ảnh</h4>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Tiêu đề</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
placeholder="Nhập tiêu đề cho ảnh..."
|
||||||
|
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Mô tả</label>
|
||||||
|
<textarea
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
|
placeholder="Mô tả bức ảnh này..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Vĩ độ</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={editLat}
|
||||||
|
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||||
|
placeholder="Vĩ độ..."
|
||||||
|
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Kinh độ</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={editLng}
|
||||||
|
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||||
|
placeholder="Kinh độ..."
|
||||||
|
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsMapOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all"
|
||||||
|
>
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||||
|
Chọn trên bản đồ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
disabled={isSavingEdit}
|
||||||
|
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all"
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSaveEdit();
|
||||||
|
}}
|
||||||
|
disabled={isSavingEdit}
|
||||||
|
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
|
||||||
|
>
|
||||||
|
{isSavingEdit ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
Đang lưu...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Lưu lại'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Title & Description display */}
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{photo.metadata?.title ? (
|
||||||
|
<h4 className="text-sm font-black text-white tracking-tight leading-snug break-words">
|
||||||
|
{photo.metadata.title}
|
||||||
|
</h4>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-slate-500 italic block mb-1">Chưa có tiêu đề</span>
|
||||||
|
)}
|
||||||
|
{photo.metadata?.description ? (
|
||||||
|
<p className="text-xs text-slate-300 leading-relaxed mt-1 max-h-20 overflow-y-auto no-scrollbar break-words">
|
||||||
|
{photo.metadata.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-slate-500 italic block mt-1">Chưa có mô tả</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAuthorized && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
|
||||||
|
title="Chỉnh sửa thông tin"
|
||||||
|
>
|
||||||
|
<Edit className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Metadata */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-800/80 pt-3 text-xs text-slate-350">
|
||||||
|
<div className="space-y-1 text-left">
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-400">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-400" title={photo.metadata?.lat && photo.metadata?.lng ? `${photo.metadata.lat.toFixed(6)}, ${photo.metadata.lng.toFixed(6)}` : ''}>
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||||
|
Địa điểm: {resolvedAddress}
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex items-center gap-1.5 text-xs text-emerald-400">
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isAuthorized && photo.originalUrl && (
|
||||||
|
<a
|
||||||
|
href={photo.originalUrl}
|
||||||
|
download
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
Tải ảnh gốc
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side: Comments */}
|
||||||
|
<div className="w-full md:w-2/5 md:flex-1 md:min-h-0 flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
|
||||||
|
|
||||||
|
{/* Comments Header */}
|
||||||
|
<div className="hidden md:block p-6 border-b border-slate-800">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-5 h-5 text-emerald-500" />
|
||||||
|
{t('commentSectionTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Ảnh chia sẻ công khai trên bản đồ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments list scroll area */}
|
||||||
|
<div className="md:flex-1 md:overflow-y-auto p-6 space-y-4 bg-slate-900/50">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
|
||||||
|
<span className="text-xs font-semibold">{t('loading')}</span>
|
||||||
|
</div>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3">
|
||||||
|
<div className="p-4 bg-slate-800/40 rounded-full text-slate-600">
|
||||||
|
<MessageSquare className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold italic">Chưa có bình luận nào. Hãy bắt đầu cuộc trò chuyện!</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
comments.map((c) => {
|
||||||
|
return (
|
||||||
|
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
|
||||||
|
<User className="w-4.5 h-4.5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[9px] font-medium text-slate-500">
|
||||||
|
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
{(currentUser?.isAdmin ||
|
||||||
|
currentUser?.id === c.userId ||
|
||||||
|
currentUser?.id === photo.uploaderId ||
|
||||||
|
currentUser?.id === photo.uploader?.id) && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteComment(c.id);
|
||||||
|
}}
|
||||||
|
className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
|
||||||
|
title={t('delete') || "Xóa"}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={commentsEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Input Area */}
|
||||||
|
<div className="sticky bottom-0 md:static p-4 bg-slate-950 md:bg-slate-950/40 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4 z-40">
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !isSending && handleSend()}
|
||||||
|
placeholder="Viết bình luận công khai..."
|
||||||
|
className="flex-1 bg-slate-800/65 border border-slate-700/70 text-slate-100 placeholder-slate-500 rounded-2xl px-4 py-3 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||||
|
disabled={isSending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSend();
|
||||||
|
}}
|
||||||
|
disabled={!newComment.trim() || isSending}
|
||||||
|
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg shadow-emerald-950/30 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<Loader2 className="w-4.5 h-4.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4.5 h-4.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CoordinateSelectModal
|
||||||
|
isOpen={isMapOpen}
|
||||||
|
onClose={() => setIsMapOpen(false)}
|
||||||
|
initialLat={typeof editLat === 'number' ? editLat : undefined}
|
||||||
|
initialLng={typeof editLng === 'number' ? editLng : undefined}
|
||||||
|
onSelect={(lat, lng) => {
|
||||||
|
setEditLat(lat);
|
||||||
|
setEditLng(lng);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isFullscreen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={photo.imageUrl}
|
||||||
|
alt="Fullscreen photo"
|
||||||
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
|
className={`max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200 ${
|
||||||
|
!isLoggedIn ? 'pointer-events-none' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, ShieldAlert, MapPin, Loader2, Phone, Mail, AlertTriangle } from 'lucide-react';
|
||||||
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
|
||||||
|
interface ReportBusinessModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialLatitude?: number;
|
||||||
|
initialLongitude?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReportBusinessModal: React.FC<ReportBusinessModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialLatitude,
|
||||||
|
initialLongitude
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [type, setType] = useState('RESTAURANT');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [latitude, setLatitude] = useState(initialLatitude ? String(initialLatitude) : '');
|
||||||
|
const [longitude, setLongitude] = useState(initialLongitude ? String(initialLongitude) : '');
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setLatitude(initialLatitude ? String(initialLatitude) : '');
|
||||||
|
setLongitude(initialLongitude ? String(initialLongitude) : '');
|
||||||
|
setSuccess(false);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [isOpen, initialLatitude, initialLongitude]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleGetCurrentLocation = () => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
setError('Trình duyệt không hỗ trợ định vị GPS.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
setLatitude(String(position.coords.latitude.toFixed(6)));
|
||||||
|
setLongitude(String(position.coords.longitude.toFixed(6)));
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setError('Không thể lấy vị trí hiện tại. Vui lòng bật định vị GPS.');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/reports`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
phone: phone || null,
|
||||||
|
email: email || null,
|
||||||
|
address: address || null,
|
||||||
|
latitude: latitude ? parseFloat(latitude) : null,
|
||||||
|
longitude: longitude ? parseFloat(longitude) : null,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Gửi báo cáo thất bại.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
// Reset form
|
||||||
|
setName('');
|
||||||
|
setPhone('');
|
||||||
|
setEmail('');
|
||||||
|
setAddress('');
|
||||||
|
setLatitude('');
|
||||||
|
setLongitude('');
|
||||||
|
setReason('');
|
||||||
|
}, 2000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content Container */}
|
||||||
|
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col max-h-[90vh]">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-red-50 text-red-500 rounded-xl">
|
||||||
|
<ShieldAlert className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">{t('reportModalTitle')}</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">Báo cáo các hành vi không lành mạnh hoặc lừa đảo kinh doanh.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success ? (
|
||||||
|
<div className="p-10 flex flex-col items-center justify-center text-center space-y-4">
|
||||||
|
<div className="w-16 h-16 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center shadow-lg animate-bounce">
|
||||||
|
<ShieldAlert className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">{t('success')}!</h3>
|
||||||
|
<p className="text-sm text-gray-500 max-w-sm">{t('reportSuccess')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1 text-left">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 text-red-600 rounded-2xl text-xs font-bold flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loại hình */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">{t('businessType')} *</label>
|
||||||
|
<select
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm font-bold text-gray-800"
|
||||||
|
>
|
||||||
|
<option value="USER">{t('typeUser')}</option>
|
||||||
|
<option value="RESTAURANT">{t('typeRestaurant')}</option>
|
||||||
|
<option value="HOTEL">{t('typeHotel')}</option>
|
||||||
|
<option value="HOMESTAY">{t('typeHomestay')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tên */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">{t('businessName')} *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="VD: Nhà hàng ABC, Homestay X..."
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* Số điện thoại */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<Phone className="w-3.5 h-3.5 text-gray-400" /> {t('businessPhone')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="0987xxxxxx"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<Mail className="w-3.5 h-3.5 text-gray-400" /> {t('businessEmail')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="contact@business.com"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Địa chỉ */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-gray-400" /> {t('businessAddress')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="VD: 123 Đường Trần Phú, Đà Lạt..."
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tọa độ địa lý */}
|
||||||
|
<div className="space-y-1.5 bg-blue-50/50 p-4 rounded-2xl border border-blue-100">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-xs font-bold text-blue-700 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
📍 Vị trí địa lý (Tùy chọn)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGetCurrentLocation}
|
||||||
|
className="text-xs font-bold text-blue-600 hover:text-blue-700 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Lấy vị trí GPS hiện tại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1">Vĩ độ (Latitude)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={latitude}
|
||||||
|
onChange={(e) => setLatitude(e.target.value)}
|
||||||
|
placeholder="11.9404"
|
||||||
|
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-xs text-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1">Kinh độ (Longitude)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={longitude}
|
||||||
|
onChange={(e) => setLongitude(e.target.value)}
|
||||||
|
placeholder="108.4382"
|
||||||
|
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-xs text-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lý do */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">{t('reportReason')} *</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="Hãy mô tả hành vi không đàng hoàng, lừa đảo hoặc gian dối của cơ sở/người dùng này..."
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all resize-none text-sm text-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-4 bg-red-600 hover:bg-red-700 text-white font-bold rounded-2xl shadow-lg shadow-red-100 transition-all flex items-center justify-center gap-2 active:scale-[0.98] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Đang gửi...' : t('submitReport')}
|
||||||
|
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : <ShieldAlert className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
|
|
||||||
|
interface TourChatProps {
|
||||||
|
tourId: string;
|
||||||
|
embedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false }) => {
|
||||||
|
const notify = useNotification();
|
||||||
|
const [messages, setMessages] = useState<any[]>([]);
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isLocating, setIsLocating] = useState(false);
|
||||||
|
|
||||||
|
const [participants, setParticipants] = useState<any[]>([]);
|
||||||
|
const [showMentionList, setShowMentionList] = useState(false);
|
||||||
|
const [mentionSearch, setMentionSearch] = useState('');
|
||||||
|
const [mentionIndex, setMentionIndex] = useState(0);
|
||||||
|
const [taggedUserIds, setTaggedUserIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const mentionRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const getHeaders = () => ({
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentUserId = (() => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return null;
|
||||||
|
const base64Url = token.split('.')[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
||||||
|
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||||
|
}).join(''));
|
||||||
|
const parsed = JSON.parse(jsonPayload);
|
||||||
|
return parsed.sub || parsed.id;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Fetch tour details to get participants (excluding current user)
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTourDetails = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/tours/${tourId}`, { headers: getHeaders() });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.participants) {
|
||||||
|
const memberList = data.participants
|
||||||
|
.map((p: any) => p.user)
|
||||||
|
.filter((u: any) => u && u.id !== currentUserId);
|
||||||
|
setParticipants(memberList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Lỗi khi tải thông tin thành viên tour:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (tourId && currentUserId) {
|
||||||
|
fetchTourDetails();
|
||||||
|
}
|
||||||
|
}, [tourId, currentUserId]);
|
||||||
|
|
||||||
|
// Click outside to close mention dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOutsideClick = (e: MouseEvent) => {
|
||||||
|
if (mentionRef.current && !mentionRef.current.contains(e.target as Node)) {
|
||||||
|
setShowMentionList(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleOutsideClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredParticipants = participants.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(mentionSearch.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setNewMessage(value);
|
||||||
|
|
||||||
|
const selectionStart = e.target.selectionStart || 0;
|
||||||
|
const textBeforeCursor = value.slice(0, selectionStart);
|
||||||
|
|
||||||
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||||
|
if (lastAtIndex !== -1) {
|
||||||
|
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||||
|
if (!textAfterAt.includes(' ')) {
|
||||||
|
setShowMentionList(true);
|
||||||
|
setMentionSearch(textAfterAt);
|
||||||
|
setMentionIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowMentionList(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!showMentionList) return;
|
||||||
|
const filtered = filteredParticipants;
|
||||||
|
if (filtered.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setMentionIndex(prev => (prev + 1) % filtered.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setMentionIndex(prev => (prev - 1 + filtered.length) % filtered.length);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
insertMention(filtered[mentionIndex]);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowMentionList(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertMention = (member: { id: string; name: string }) => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const selectionStart = input.selectionStart || 0;
|
||||||
|
const textBeforeCursor = newMessage.slice(0, selectionStart);
|
||||||
|
const textAfterCursor = newMessage.slice(selectionStart);
|
||||||
|
|
||||||
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||||
|
if (lastAtIndex !== -1) {
|
||||||
|
const newTextBeforeCursor = textBeforeCursor.slice(0, lastAtIndex) + `@${member.name} `;
|
||||||
|
const updatedValue = newTextBeforeCursor + textAfterCursor;
|
||||||
|
|
||||||
|
setNewMessage(updatedValue);
|
||||||
|
setShowMentionList(false);
|
||||||
|
|
||||||
|
if (!taggedUserIds.includes(member.id)) {
|
||||||
|
setTaggedUserIds(prev => [...prev, member.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
input.focus();
|
||||||
|
const cursorPosition = newTextBeforeCursor.length;
|
||||||
|
input.setSelectionRange(cursorPosition, cursorPosition);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch past messages
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/tours/${tourId}/messages`, { headers: getHeaders() });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessages(data || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Lỗi khi tải tin nhắn:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMessages();
|
||||||
|
}, [tourId]);
|
||||||
|
|
||||||
|
// Connect to socket and listen for tour messages
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = io();
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.emit('joinTour', tourId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('tourMessageReceived', (data: any) => {
|
||||||
|
if (data.tourId === tourId) {
|
||||||
|
setMessages(prev => [...prev, data.message]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [tourId]);
|
||||||
|
|
||||||
|
// Autoscroll chat to bottom
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Compress image to 2K (max 2048px longest side)
|
||||||
|
const compressImageTo2K = (file: File): Promise<Blob> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = event.target?.result as string;
|
||||||
|
img.onload = () => {
|
||||||
|
const MAX_DIM = 2048;
|
||||||
|
let width = img.width;
|
||||||
|
let height = img.height;
|
||||||
|
|
||||||
|
if (width > MAX_DIM || height > MAX_DIM) {
|
||||||
|
if (width > height) {
|
||||||
|
height = Math.round((height * MAX_DIM) / width);
|
||||||
|
width = MAX_DIM;
|
||||||
|
} else {
|
||||||
|
width = Math.round((width * MAX_DIM) / height);
|
||||||
|
height = MAX_DIM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
resolve(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
resolve(file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
0.85
|
||||||
|
);
|
||||||
|
};
|
||||||
|
img.onerror = (err) => reject(err);
|
||||||
|
};
|
||||||
|
reader.onerror = (err) => reject(err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Image Selection
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setSelectedImage(file);
|
||||||
|
setImagePreview(URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Location Sharing
|
||||||
|
const handleGetLocation = () => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
notify({
|
||||||
|
title: 'Không hỗ trợ',
|
||||||
|
message: 'Trình duyệt của bạn không hỗ trợ định vị GPS.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLocating(true);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
setAttachedLocation({
|
||||||
|
latitude: position.coords.latitude,
|
||||||
|
longitude: position.coords.longitude
|
||||||
|
});
|
||||||
|
notify({
|
||||||
|
title: 'Gắn vị trí thành công',
|
||||||
|
message: 'Vị trí hiện tại đã được đính kèm vào tin nhắn.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
setIsLocating(false);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('Lỗi định vị:', error);
|
||||||
|
notify({
|
||||||
|
title: 'Lỗi GPS',
|
||||||
|
message: 'Không thể lấy vị trí hiện tại của bạn. Hãy kiểm tra quyền truy cập.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
setIsLocating(false);
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload image to backend
|
||||||
|
const uploadImage = async (file: File): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
// Compress first
|
||||||
|
const compressedBlob = await compressImageTo2K(file);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', compressedBlob, 'compressed.jpg');
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Lỗi upload ảnh:', err);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send Tour Message
|
||||||
|
const handleSendMessage = async (e?: React.FormEvent) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
if (!newMessage.trim() && !selectedImage && !attachedLocation) return;
|
||||||
|
|
||||||
|
let attachmentUrl = undefined;
|
||||||
|
if (selectedImage) {
|
||||||
|
attachmentUrl = await uploadImage(selectedImage);
|
||||||
|
if (!attachmentUrl) {
|
||||||
|
notify({
|
||||||
|
title: 'Lỗi',
|
||||||
|
message: 'Không thể tải ảnh đính kèm lên server.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualTaggedUserIds = taggedUserIds.filter(userId => {
|
||||||
|
const member = participants.find(p => p.id === userId);
|
||||||
|
if (!member || !member.name) return false;
|
||||||
|
|
||||||
|
const cleanMessage = newMessage.toLowerCase();
|
||||||
|
const nameLower = member.name.toLowerCase();
|
||||||
|
|
||||||
|
// Try exact match first
|
||||||
|
if (cleanMessage.includes(`@${nameLower}`)) return true;
|
||||||
|
|
||||||
|
// Try match without parentheses (e.g. "Lộc Phạm (Chủ Tour)" -> "Lộc Phạm")
|
||||||
|
const nameWithoutParentheses = member.name.split('(')[0].trim().toLowerCase();
|
||||||
|
if (nameWithoutParentheses && cleanMessage.includes(`@${nameWithoutParentheses}`)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
content: newMessage,
|
||||||
|
attachmentUrl,
|
||||||
|
latitude: attachedLocation?.latitude,
|
||||||
|
longitude: attachedLocation?.longitude,
|
||||||
|
taggedUserIds: actualTaggedUserIds
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset input fields immediately
|
||||||
|
setNewMessage('');
|
||||||
|
setSelectedImage(null);
|
||||||
|
setImagePreview(null);
|
||||||
|
setAttachedLocation(null);
|
||||||
|
setTaggedUserIds([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/tours/${tourId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
notify({
|
||||||
|
title: 'Lỗi',
|
||||||
|
message: 'Gửi tin nhắn thất bại.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Lỗi gửi tin nhắn:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download image file helper
|
||||||
|
const handleDownloadImage = async (url: string, id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = `tour-chat-photo-${id}.jpg`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi tải ảnh:', error);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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...
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-gray-450 text-xs italic gap-1.5">
|
||||||
|
<MessageSquare className="w-8 h-8 text-gray-300" />
|
||||||
|
<span className="text-gray-400">Chưa có tin nhắn nào trong phòng chat nhóm này.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => {
|
||||||
|
const isMe = msg.senderId === currentUserId;
|
||||||
|
const initials = msg.sender?.name
|
||||||
|
? msg.sender.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()
|
||||||
|
: 'U';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex gap-2 max-w-[80%] ${isMe ? 'self-end flex-row-reverse' : 'self-start'}`}
|
||||||
|
>
|
||||||
|
{!isMe && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-indigo-500 flex items-center justify-center font-bold text-[10px] text-white shadow-sm shrink-0">
|
||||||
|
{msg.sender?.avatar ? (
|
||||||
|
<img src={msg.sender.avatar} alt={msg.sender.name} className="w-full h-full rounded-full object-cover" />
|
||||||
|
) : initials}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
|
||||||
|
{!isMe && (
|
||||||
|
<span className="text-[10px] font-bold text-gray-500 mb-0.5 ml-1">
|
||||||
|
{msg.sender?.name || 'Thành viên'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<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-200 shadow-sm'
|
||||||
|
}`}>
|
||||||
|
{/* Attachment Image */}
|
||||||
|
{msg.attachmentUrl && (
|
||||||
|
<div className="relative rounded-lg overflow-hidden border border-black/5 max-w-xs group/img">
|
||||||
|
<img
|
||||||
|
src={msg.attachmentUrl}
|
||||||
|
alt="Đính kèm"
|
||||||
|
className="w-full max-h-48 object-cover hover:brightness-95 transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDownloadImage(msg.attachmentUrl, msg.id)}
|
||||||
|
className="absolute bottom-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md transition-all shadow-md flex items-center justify-center"
|
||||||
|
title="Tải ảnh này về máy"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GPS Location badge */}
|
||||||
|
{msg.latitude !== undefined && msg.latitude !== null && (
|
||||||
|
<a
|
||||||
|
href={`https://www.google.com/maps/search/?api=1&query=${msg.latitude},${msg.longitude}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
|
||||||
|
<div className="flex flex-col text-left">
|
||||||
|
<span>Vị trí hiện tại</span>
|
||||||
|
<span className="text-[9px] opacity-75">{msg.latitude.toFixed(6)}, {msg.longitude.toFixed(6)}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content text */}
|
||||||
|
{msg.content && <p className="whitespace-pre-wrap break-words">{msg.content}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="text-[8px] text-gray-400 font-bold mt-1 px-1">
|
||||||
|
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Previews (Image & GPS Location) */}
|
||||||
|
{(imagePreview || attachedLocation) && (
|
||||||
|
<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" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedImage(null);
|
||||||
|
setImagePreview(null);
|
||||||
|
}}
|
||||||
|
className="absolute top-0.5 right-0.5 p-0.5 bg-black/60 hover:bg-black text-white rounded-full transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{attachedLocation && (
|
||||||
|
<div className="flex items-center gap-1.5 bg-rose-50 border border-rose-200 rounded-lg px-2.5 py-1 text-xs text-rose-700 font-bold">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-rose-500 animate-pulse" />
|
||||||
|
<span>Đã đính kèm GPS</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAttachedLocation(null)}
|
||||||
|
className="hover:text-rose-950 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
ref={mentionRef}
|
||||||
|
className="absolute bottom-full left-3 right-3 mb-2 bg-white border border-gray-200 rounded-xl shadow-xl max-h-40 overflow-y-auto z-50 flex flex-col py-1"
|
||||||
|
>
|
||||||
|
{filteredParticipants.map((member, index) => (
|
||||||
|
<button
|
||||||
|
key={member.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMention(member)}
|
||||||
|
className={`px-3 py-2 text-left text-xs font-semibold flex items-center gap-2 transition-colors ${
|
||||||
|
index === mentionIndex
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center font-bold text-[9px] text-blue-600">
|
||||||
|
{member.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span>{member.name}</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-medium font-mono">@{member.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chat Input form */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSendMessage}
|
||||||
|
className="p-3 flex gap-2 items-center !w-full relative"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleImageChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Attach photo button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0"
|
||||||
|
title="Đính kèm hình ảnh"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-4 h-4 text-blue-500" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Share current GPS button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGetLocation}
|
||||||
|
disabled={isLocating}
|
||||||
|
className={`p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0 ${
|
||||||
|
isLocating ? 'animate-pulse' : ''
|
||||||
|
}`}
|
||||||
|
title="Chia sẻ vị trí GPS hiện tại"
|
||||||
|
>
|
||||||
|
{isLocating ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-rose-500" />
|
||||||
|
) : (
|
||||||
|
<MapPin className="w-4 h-4 text-rose-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
ref={inputRef}
|
||||||
|
value={newMessage}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={isUploading ? "Đang tải ảnh lên..." : "Nhập nội dung tin nhắn..."}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="flex-1 bg-white border border-gray-200 rounded-xl py-2.5 px-3 text-xs text-gray-800 placeholder-gray-400 outline-none focus:border-blue-500 transition-all shadow-inner disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isUploading || (!newMessage.trim() && !selectedImage && !attachedLocation)}
|
||||||
|
className="p-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
|
|
||||||
|
interface ConfirmOptions {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmContext = createContext<((options: ConfirmOptions) => Promise<boolean>) | undefined>(undefined);
|
||||||
|
|
||||||
|
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
resolve?: (value: boolean) => void;
|
||||||
|
}>({ isOpen: false });
|
||||||
|
|
||||||
|
const confirm = useCallback((options: ConfirmOptions) => {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
setState({
|
||||||
|
isOpen: true,
|
||||||
|
title: options.title,
|
||||||
|
message: options.message,
|
||||||
|
resolve,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const resolve = state.resolve;
|
||||||
|
setState({ isOpen: false, resolve: undefined });
|
||||||
|
resolve?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
const resolve = state.resolve;
|
||||||
|
setState({ isOpen: false, resolve: undefined });
|
||||||
|
resolve?.(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmContext.Provider value={confirm}>
|
||||||
|
{children}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={state.isOpen}
|
||||||
|
title={state.title}
|
||||||
|
message={state.message}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</ConfirmContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfirm = () => {
|
||||||
|
const confirm = useContext(ConfirmContext);
|
||||||
|
if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider');
|
||||||
|
return confirm;
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
const loadScript = (src: string, fallbackSrcs?: string[]): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const allSrcs = [src, ...(fallbackSrcs || [])];
|
||||||
|
|
||||||
|
// Check if any of the scripts are already loaded
|
||||||
|
if (allSrcs.some(s => document.querySelector(`script[src="${s}"]`))) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryLoadScript = (index: number) => {
|
||||||
|
if (index >= allSrcs.length) {
|
||||||
|
reject(new Error(`Failed to load script from any source: ${allSrcs.join(', ')}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSrc = allSrcs[index];
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = currentSrc;
|
||||||
|
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => {
|
||||||
|
console.warn(`Failed to load script ${currentSrc}. Trying next fallback...`);
|
||||||
|
const nextIndex = index + 1;
|
||||||
|
if (nextIndex < allSrcs.length) {
|
||||||
|
tryLoadScript(nextIndex);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Failed to load script from all sources: ${allSrcs.join(', ')}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
};
|
||||||
|
|
||||||
|
tryLoadScript(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadModerationLibraries = async () => {
|
||||||
|
// Load TensorFlow first with fallbacks
|
||||||
|
await loadScript(
|
||||||
|
'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs',
|
||||||
|
['https://unpkg.com/@tensorflow/tfjs', 'https://esm.sh/@tensorflow/tfjs']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load models after tfjs is available, with multiple fallbacks
|
||||||
|
await Promise.all([
|
||||||
|
loadScript(
|
||||||
|
'https://cdn.jsdelivr.net/npm/@tensorflow-models/blazeface',
|
||||||
|
['https://unpkg.com/@tensorflow-models/blazeface', 'https://esm.sh/@tensorflow-models/blazeface']
|
||||||
|
),
|
||||||
|
// NSFWJS with 3 CDN fallbacks
|
||||||
|
loadScript(
|
||||||
|
'https://cdn.jsdelivr.net/npm/nsfwjs@2.4.0/dist/bundle.js',
|
||||||
|
[
|
||||||
|
'https://unpkg.com/nsfwjs@2.4.0/dist/bundle.js',
|
||||||
|
'https://esm.sh/nsfwjs@2.4.0/dist/bundle.js'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processImageModeration = async (file: File): Promise<{ file: File; blocked: boolean }> => {
|
||||||
|
try {
|
||||||
|
const settingsRes = await fetch('/api/v1/moderation/settings');
|
||||||
|
if (!settingsRes.ok) return { file, blocked: false };
|
||||||
|
const settings = await settingsRes.json();
|
||||||
|
const { blockNsfw, blurFaces } = settings;
|
||||||
|
|
||||||
|
if (!blockNsfw && !blurFaces) {
|
||||||
|
return { file, blocked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load moderation libraries, but don't fail if they're unavailable
|
||||||
|
try {
|
||||||
|
await loadModerationLibraries();
|
||||||
|
} catch (libLoadErr) {
|
||||||
|
console.warn('Moderation libraries failed to load, proceeding without NSFW/Face blur checks:', libLoadErr);
|
||||||
|
return { file, blocked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
resolve({ file, blocked: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
if (blockNsfw) {
|
||||||
|
try {
|
||||||
|
const nsfwModel = await (window as any).nsfwjs?.load();
|
||||||
|
if (nsfwModel) {
|
||||||
|
const predictions = await nsfwModel.classify(canvas);
|
||||||
|
const pornOrHentai = predictions.find((p: any) => p.className === 'Porn' || p.className === 'Hentai');
|
||||||
|
const pornProb = pornOrHentai ? pornOrHentai.probability : 0;
|
||||||
|
if (pornProb > 0.5) {
|
||||||
|
console.warn(`Image blocked by NSFW filter (probability: ${pornProb})`);
|
||||||
|
resolve({ file, blocked: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('NSFW validation error (will allow upload):', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let modified = false;
|
||||||
|
if (blurFaces) {
|
||||||
|
try {
|
||||||
|
const blazefaceModel = await (window as any).blazeface?.load();
|
||||||
|
if (blazefaceModel) {
|
||||||
|
const predictions = await blazefaceModel.estimateFaces(canvas, false);
|
||||||
|
if (predictions && predictions.length > 0) {
|
||||||
|
modified = true;
|
||||||
|
predictions.forEach((prediction: any) => {
|
||||||
|
const startX = prediction.topLeft[0];
|
||||||
|
const startY = prediction.topLeft[1];
|
||||||
|
const endX = prediction.bottomRight[0];
|
||||||
|
const endY = prediction.bottomRight[1];
|
||||||
|
const width = endX - startX;
|
||||||
|
const height = endY - startY;
|
||||||
|
|
||||||
|
const faceCanvas = document.createElement('canvas');
|
||||||
|
faceCanvas.width = width;
|
||||||
|
faceCanvas.height = height;
|
||||||
|
const faceCtx = faceCanvas.getContext('2d');
|
||||||
|
if (faceCtx) {
|
||||||
|
faceCtx.drawImage(canvas, startX, startY, width, height, 0, 0, width, height);
|
||||||
|
ctx.filter = 'blur(15px)';
|
||||||
|
ctx.drawImage(faceCanvas, startX, startY, width, height);
|
||||||
|
ctx.filter = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Face blur error (will skip face detection):', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
const processedFile = new File([blob], file.name, { type: file.type });
|
||||||
|
resolve({ file: processedFile, blocked: false });
|
||||||
|
} else {
|
||||||
|
resolve({ file, blocked: false });
|
||||||
|
}
|
||||||
|
}, file.type);
|
||||||
|
} else {
|
||||||
|
resolve({ file, blocked: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
resolve({ file, blocked: false });
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Image moderation process failed:', err);
|
||||||
|
return { file, blocked: false };
|
||||||
|
}
|
||||||
|
};
|
||||||