7 Commits

50 changed files with 1637 additions and 558 deletions
-102
View File
@@ -1,102 +0,0 @@
# To AI Agent: Complete Component Relocation - Move Navigation Tabs to Top Header and Fix Button Text Wrapping
## 1. Context & Structural Defects Analysis
We are fixing a persistent UI layout failure on the mobile view (`yotrip.labz.io.vn`) as highlighted in the annotated screenshot `image_c61225.png`:
- **Component Misplacement (Lower Red Box):** The sub-navigation tabs (`Lộ trình`, `Chi phí`, `Ảnh`, `Trò chuyện`...) are currently detached and floating erratically in the middle of the chat history viewport, overlapping message bubbles.
- **Empty Empty Top Gap (Upper Red Box):** There is an empty dark/blank bar container sitting directly below the Main Tour Title Header ("Hoa vàng cỏ xanh 20...").
- **Text Wrapping Bug:** The labels on the floating navigation items are wrapping onto multiple lines (e.g., `Lộ\ntrình`, `Chi\nphí`), ruining the horizontal tab presentation.
---
## 2. Refactoring Objectives
1. **DOM Relocation:** Completely extract the Sub-Navigation Tabs out of the chat message block. Relocate and mount it directly into the upper red box zone so it sits flush immediately underneath the Main Tour Header.
2. **Text & Width Optimization:** Enforce strict styles on the tab container so that all navigation buttons adjust dynamically to fit within the viewport width. The button text names **MUST NOT** wrap down to a new line (`white-space: nowrap`).
---
## 3. Targeted Fix Instructions
### Step 1: Correct the DOM / React Component Tree Hierarchy
Ensure the navigation component sits inside the sticky top layout zone, completely separated from the scrollable chat list container:
```html
<div class="chat-viewport-wrapper">
<div class="fixed-top-header-block">
<header class="main-tour-header">
</header>
<nav class="sub-navigation-tabs">
<button class="nav-tab-item">Lộ trình</button>
<button class="nav-tab-item">Chi phí</button>
<button class="nav-tab-item">Ảnh</button>
<button class="nav-tab-item active">Trò chuyện</button>
<button class="nav-tab-item">Thành viên</button>
<button class="nav-tab-item">Cài đặt</button>
</nav>
</div>
<main class="chat-messages-scroll-area">
</main>
</div>
### Step 2: Apply CSS/Tailwind Rules for Button Shrinkage & Single-Line Restraints
Apply these structural adjustments to make sure the tabs fill the screen width evenly without line breaks:
/* Container for header components stacked on top */
.fixed-top-header-block {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
position: sticky !important;
top: 0 !important;
z-index: 50 !important;
background-color: #ffffff;
}
/* FIX: Ensure tabs fit inside the viewport layout cleanly */
.sub-navigation-tabs {
display: flex !important;
width: 100% !important;
margin: 0 !important;
padding: 4px 8px !important; /* Tight padding to utilize screen real estate */
border-radius: 0 !important;
box-shadow: none !important;
border-bottom: 1px solid #e5e7eb;
background-color: #ffffff;
/* Distribute items evenly across the width */
justify-content: space-between !important;
align-items: center !important;
}
/* FIX: Absolute enforcement of no word-wrapping on buttons */
.nav-tab-item {
display: flex !important;
flex-direction: column !important; /* Stack icon and text if applicable */
align-items: center !important;
justify-content: center !important;
flex: 1 1 0% !important; /* Allow dynamic even shrinkage */
min-width: 0 !important; /* Overcomes default flex minimums */
/* Text layout fixes */
white-space: nowrap !important; /* PREVENT TEXT FROM WRAPPING TO NEW LINE */
font-size: 11px !important; /* Scaled down to prevent out-of-bounds clipping */
padding: 4px 2px !important;
text-align: center !important;
}
/* Handle icon sizes inside tabs if applicable */
.nav-tab-item svg, .nav-tab-item i {
font-size: 16px !important;
margin-bottom: 2px !important;
}
## 4. Verification Check for AI Agent
[ ] Verify that the navigation row is no longer hovering over the chat text bubble flow.
[ ] Confirm there is zero dead space or black margin layers between the tour header and the navigation buttons.
[ ] Inspect text elements for words like "Lộ trình" and "Chi phí"—they must read completely on a single line horizontally.
+156
View File
@@ -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
+116
View File
@@ -0,0 +1,116 @@
# To AI Agent: Implement Unified Dual-Theme System (Light & Dark) for Yotrip App
## 1. Context & Design System Overview
We are building a robust, high-contrast, yet eye-friendly dual-theme system (Light and Dark modes) for our mobile application (`yotrip.labz.io.vn`).
- **Dark Theme Constraint:** Must use the pre-configured smoky grape base (`Vintage Grape` & `Dusty Grape`) with vivid lime/aquatic accents. No pitch-black.
- **Light Theme Constraint:** Must use the soft parchment base (`Parchment`) to eliminate screen glare, using deep academic blue (`Yale Blue`) for high-contrast readability. No pure blazing white backgrounds.
---
## 2. Comprehensive Color Token Mapping
### A. LIGHT THEME PALETTE ("Classic Heritage & Soft Sand")
- **`Parchment` (`#f6f0ed`):** Map to **Global Page Backgrounds**. A warm, ancient-manuscript off-white that eliminates mobile screen glare.
- **`Yale Blue` (`#28536b`):** Map to **Primary Text, Main Titles, and Primary Action Buttons**. Provides dependable trust and sharp contrast.
- **`Steel Blue` (`#7ea8be`):** Map to **Active Navigation Tabs, Active Icons, and Secondary Actions**.
- **`Khaki Beige` (`#bbb193`):** Map to **Borders, Dividers, and Deactivated/Muted States**.
- **`Rosy Taupe` (`#c2948a`):** Map to **Special Highlight Badges, Notification Banners, or Warm Accent Elements**.
### B. DARK THEME PALETTE ("Vintage Velvet & Aquatic Zest")
- **`Vintage Grape` (`#513b56`):** Map to **Global Page Backgrounds**.
- **`Dusty Grape` (`#525174`):** Map to **Component Surfaces (Cards, Chat Bubbles, Accordion Rows)**.
- **`Lime Cream` (`#bce784`):** Map to **Brand Text Highlights, Active Icons**.
- **`Bondi Blue` (`#348aa7`):** Map to **Primary Action Buttons, Links**.
- **`Emerald` (`#5dd39e`):** Map to **Success Utilities & "Tối ưu" badges**.
---
## 3. Technical Global Configuration
### Option A: Clean CSS Variables (`global.css`)
Replace or update the root variable tokens inside your main stylesheet:
```css
/* --- THEME SÁNG (Mềm mại, Tương phản cao, Không chói) --- */
:root {
--background: #f6f0ed; /* Parchment */
--surface: #ffffff; /* Pure White for crisp card layers */
--surface-muted: #bbb193; /* Khaki Beige */
--border: #bbb193; /* Khaki Beige */
--text-primary: #28536b; /* Yale Blue (High contrast text) */
--text-secondary: #7ea8be; /* Steel Blue */
--text-accent: #c2948a; /* Rosy Taupe */
--primary: #28536b; /* Yale Blue for main buttons */
--primary-hover: #1f4154;
--secondary-active: #7ea8be; /* Steel Blue */
}
/* --- THEME TỐI (Dịu mắt, Sang trọng, Không đen kịt) --- */
:root.dark {
--background: #513b56; /* Vintage Grape */
--surface: #525174; /* Dusty Grape */
--surface-muted: #626186;
--border: #626186;
--text-primary: #f8fafc; /* Soft White */
--text-secondary: #94a3b8; /* Muted Slate */
--text-brand: #bce784; /* Lime Cream */
--primary: #348aa7; /* Bondi Blue */
--primary-hover: #296f86;
--success-accent: #5dd39e; /* Emerald */
}
### Option B: Tailwind Extension Configuration (tailwind.config.js)
Expose these custom design tokens cleanly into the utility library framework:
theme: {
extend: {
colors: {
yotripLight: {
bg: '#f6f0ed', // Parchment
text: '#28536b', // Yale Blue
steel: '#7ea8be', // Steel Blue
khaki: '#bbb193', // Khaki Beige
rosy: '#c2948a', // Rosy Taupe
},
yotripDark: {
bg: '#513b56', // Vintage Grape
surface: '#525174', // Dusty Grape
lime: '#bce784', // Lime Cream
bondi: '#348aa7', // Bondi Blue
emerald: '#5dd39e', // Emerald
}
}
}
}
## 4. UI Component Application Reference
Apply these unified color classes to ensure the interface flips seamlessly:
Chat Wrapper & Itinerary Timelines: - Light Mode: Background is yotripLight-bg, General Text is yotripLight-text.
Dark Mode: Background is yotripDark-bg, General Text is #f8fafc.
Navigation Tabs Menu:
Light Mode: Inactive text is yotripLight-steel. Active tab gets a yotripLight-text highlight indicator.
Dark Mode: Inactive text is yotripDark-surface. Active tab gets a yotripDark-lime highlight indicator.
Main Action Call-To-Actions (e.g., "Thêm địa điểm"):
Light Mode: Background is yotripLight-text (Yale Blue) with clean white text.
Dark Mode: Background is yotripDark-bondi (Bondi Blue) with clean white text.
## 5. Acceptance Criteria for Verification
[ ] Ensure that toggling the .dark class on the <html> root triggers a global transition without style flashes (transition-colors duration-200).
[ ] Text legibility inside Light Mode satisfies standard WCAG accessibility contrast limits on mobile displays out in the sun.
[ ] The background of the expanded folder nodes/chat bubbles maps cleanly to their respective surface tokens across both system states.
+16 -1
View File
@@ -2240,6 +2240,20 @@ class PhotoController {
} }
} }
// Lấy tags từ request (nếu có)
let tags: string[] = [];
if (req.body.tags) {
try {
tags = JSON.parse(req.body.tags);
if (!Array.isArray(tags)) {
tags = [];
}
} catch (e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
console.warn('[TAGS] Failed to parse tags from request:', errorMsg);
}
}
// 4. Nếu vẫn không có, sử dụng vị trí mặc định (TP.HCM) // 4. Nếu vẫn không có, sử dụng vị trí mặc định (TP.HCM)
if (lat === undefined || lng === undefined) { if (lat === undefined || lng === undefined) {
lat = 10.7769; lat = 10.7769;
@@ -2278,7 +2292,8 @@ class PhotoController {
privacy: 'PUBLIC', privacy: 'PUBLIC',
metadata: { metadata: {
lat: lat, lat: lat,
lng: lng lng: lng,
tags: tags
} }
}, },
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Before

Width:  |  Height:  |  Size: 844 KiB

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

+15
View File
@@ -6,6 +6,21 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script src="https://accounts.google.com/gsi/client" async defer></script> <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>
+29 -8
View File
@@ -3,10 +3,39 @@ server {
server_name yotrip.labz.io.vn localhost; server_name yotrip.labz.io.vn localhost;
client_max_body_size 50M; 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 / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;
# Only redirect actual routes to index.html, not assets
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
} }
# Proxy API requests to backend # Proxy API requests to backend
@@ -19,14 +48,6 @@ server {
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
# Serve uploaded files from backend
location /uploads/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Proxy WebSocket connection # Proxy WebSocket connection
location /socket.io/ { location /socket.io/ {
proxy_pass http://backend:3001; proxy_pass http://backend:3001;
+99 -43
View File
@@ -45,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>
@@ -88,6 +88,45 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
const { legs, addLocation, updateLocation, updateTourStartPoint, updateTourEndPoint, mapCenter, currentTour, userRole } = useTourStore(); const { legs, addLocation, updateLocation, updateTourStartPoint, updateTourEndPoint, mapCenter, currentTour, userRole } = useTourStore();
const notify = useNotification(); 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(() => {
if (isOpen) { if (isOpen) {
@@ -348,18 +387,18 @@ 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-64 w-full rounded-3xl overflow-hidden mb-6 border border-gray-100 relative shadow-xl group"> <div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-[var(--border)] relative shadow-xl group">
{/* Map Search Bar Overlay - Tích hợp tìm kiếm trực tiếp trên bản đồ */} {/* Map Search Bar Overlay - Tích hợp tìm kiếm trực tiếp trên bản đồ */}
<div className="absolute top-3 left-3 right-3 z-[1001] pointer-events-none"> <div className="absolute top-3 left-3 right-3 z-[1001] pointer-events-none">
<div className="relative max-w-sm pointer-events-auto"> <div className="relative max-w-sm pointer-events-auto">
@@ -367,7 +406,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<input <input
type="text" type="text"
placeholder="Tìm địa điểm trên bản đồ..." placeholder="Tìm địa điểm trên bản đồ..."
className="w-full pl-11 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold text-gray-800" className="w-full pl-11 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold text-[var(--text-primary)]"
value={formData.name} value={formData.name}
onChange={e => handleSearchLocation(e.target.value)} onChange={e => handleSearchLocation(e.target.value)}
/> />
@@ -378,7 +417,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<button <button
type="button" type="button"
onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }} onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-full text-gray-400 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-[var(--background)] rounded-full text-[var(--text-muted)] transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -387,24 +426,24 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
{/* Dropdown kết quả tìm kiếm ngay trong khung bản đồ */} {/* Dropdown kết quả tìm kiếm ngay trong khung bản đồ */}
{(searchResults.length > 0 || hasNoResults) && ( {(searchResults.length > 0 || hasNoResults) && (
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-40 overflow-y-auto animate-in slide-in-from-top-2 duration-200"> <div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-[var(--border)] rounded-2xl shadow-2xl overflow-hidden max-h-40 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
{hasNoResults ? ( {hasNoResults ? (
<div className="px-4 py-4 text-center text-gray-400 text-xs italic">Không tìm thấy đa điểm phù hợp...</div> <div className="px-4 py-4 text-center text-[var(--text-muted)] text-xs italic">Không tìm thấy đa điểm phù hợp...</div>
) : ( ) : (
searchResults.map((result, idx) => ( searchResults.map((result, idx) => (
<button <button
key={idx} key={idx}
type="button" type="button"
onClick={() => selectSearchResult(result)} onClick={() => selectSearchResult(result)}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors flex flex-col gap-0.5" className="w-full text-left px-4 py-3 hover:bg-[var(--background)] border-b border-[var(--border)] last:border-0 transition-colors flex flex-col gap-0.5"
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div> <div className="font-bold text-xs text-[var(--text-primary)] truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
{result.type && ( {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> <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>
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div> <div className="text-[10px] text-[var(--text-muted)] truncate leading-tight">{result.display_name}</div>
</button> </button>
)) ))
)} )}
@@ -418,7 +457,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<Marker position={currentCoords} /> <Marker position={currentCoords} />
<MapPicker center={currentCoords} onPick={handlePickLocation} /> <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>
@@ -436,23 +475,23 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<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 <input
required required
placeholder="Tên địa điểm..." placeholder="Tên địa điểm..."
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all font-bold" className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all font-bold"
value={formData.name} value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})} 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>
@@ -460,8 +499,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<p className="text-xs font-black text-blue-500 uppercase tracking-widest">Chi phí nhanh tại điểm này</p> <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="text" 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 => { value={formData.expenseAmount} onChange={e => {
const rawValue = e.target.value.replace(/\D/g, ""); // Chỉ lấy số const rawValue = e.target.value.replace(/\D/g, ""); // Chỉ lấy số
@@ -470,8 +509,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
}} /> }} />
</div> </div>
<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>
@@ -482,20 +521,20 @@ 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ụ / tả</label> <label className="block text-xs font-bold text-[var(--text-secondary)] mb-1">Dịch vụ / 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?.filter((p: any) => p.user || p.displayName)?.map((p: any) => { {currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.map((p: any) => {
@@ -510,8 +549,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
</div> </div>
</div> </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}> <option key={leg.id} value={leg.id}>
@@ -523,19 +562,36 @@ 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"> đ</label> <label className="block text-sm font-bold text-[var(--text-secondary)] mb-1"> đ</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ả đ 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>
@@ -545,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>
+8 -8
View File
@@ -174,12 +174,12 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
return ( return (
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4"> <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="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200"> <div className="relative w-full max-w-lg bg-[var(--surface)] rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2"> <h2 className="text-2xl font-bold text-[var(--text-primary)] flex items-center gap-2">
<ImageIcon className="w-6 h-6 text-blue-600" /> Tải nh lên <ImageIcon className="w-6 h-6 text-blue-600" /> Tải nh lên
</h2> </h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400"> <button onClick={onClose} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors text-[var(--text-muted)]">
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
@@ -187,22 +187,22 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0"> <form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
<div <div
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-200 rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-blue-50/50 hover:border-blue-200 transition-all mb-6 group" className="border-2 border-dashed border-[var(--border)] rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-[var(--background)]/50 hover:border-blue-200 transition-all mb-6 group"
> >
<input type="file" ref={fileInputRef} className="hidden" multiple accept="image/*" onChange={handleFileChange} /> <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"> <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" /> <Upload className="w-8 h-8" />
</div> </div>
<p className="text-sm font-black text-gray-700">Nhấn đ chọn nh</p> <p className="text-sm font-black text-[var(--text-secondary)]">Nhấn đ chọn nh</p>
<p className="text-xs text-gray-400 mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p> <p className="text-xs text-[var(--text-muted)] mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p>
</div> </div>
{previews.length > 0 && ( {previews.length > 0 && (
<div className="flex-1 overflow-y-auto mb-6 pr-2"> <div className="flex-1 overflow-y-auto mb-6 pr-2">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p> <p className="text-[10px] font-black text-[var(--text-muted)] uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{previews.map((src, idx) => ( {previews.map((src, idx) => (
<div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-gray-100 shadow-sm group"> <div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-[var(--border)] shadow-sm group">
<img src={src} className="w-full h-full object-cover" alt="preview" /> <img src={src} className="w-full h-full object-cover" alt="preview" />
<button <button
type="button" type="button"
+15 -15
View File
@@ -134,27 +134,27 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
return ( return (
<div className="fixed inset-0 z-[5000] flex items-center justify-center p-4"> <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="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onClose} />
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200"> <div className="relative w-full max-w-md bg-[var(--surface)] rounded-[32px] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
{/* Header */} {/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10"> <div className="p-6 border-b border-[var(--border)] flex justify-between items-center bg-[var(--surface)] sticky top-0 z-10">
<div> <div>
<h3 className="text-xl font-black text-gray-900 flex items-center gap-2"> <h3 className="text-xl font-black text-[var(--text-primary)] flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600" /> <MessageSquare className="w-5 h-5 text-blue-600" />
Bình luận Bình luận
</h3> </h3>
<p className="text-xs text-gray-500 font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p> <p className="text-xs text-[var(--text-muted)] font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p>
</div> </div>
<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-5 h-5 text-gray-400" /> <X className="w-5 h-5 text-[var(--text-muted)]" />
</button> </button>
</div> </div>
{/* Comment List */} {/* Comment List */}
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50/50"> <div className="flex-1 overflow-y-auto p-6 space-y-4 bg-[var(--background)]/50">
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-10"><Loader2 className="w-6 h-6 animate-spin text-blue-600" /></div> <div className="flex justify-center py-10"><Loader2 className="w-6 h-6 animate-spin text-blue-600" /></div>
) : comments.length === 0 ? ( ) : comments.length === 0 ? (
<div className="text-center py-10 text-gray-400 italic text-sm">Chưa bình luận nào.</div> <div className="text-center py-10 text-[var(--text-muted)] italic text-sm">Chưa bình luận nào.</div>
) : ( ) : (
comments.map((c) => ( comments.map((c) => (
<div key={c.id} className="flex gap-3 animate-in slide-in-from-left-2 duration-300"> <div key={c.id} className="flex gap-3 animate-in slide-in-from-left-2 duration-300">
@@ -162,21 +162,21 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
<User className="w-4 h-4 text-blue-600" /> <User className="w-4 h-4 text-blue-600" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm"> <div className="bg-[var(--surface)] p-3 rounded-2xl rounded-tl-none border border-[var(--border)] shadow-sm">
<div className="flex justify-between items-start mb-1"> <div className="flex justify-between items-start mb-1">
<p className="text-xs font-black text-gray-900">{c.userName}</p> <p className="text-xs font-black text-[var(--text-primary)]">{c.userName}</p>
{(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && ( {(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && (
<button <button
onClick={() => setConfirmState({ open: true, commentId: c.id })} onClick={() => setConfirmState({ open: true, commentId: c.id })}
className="text-gray-400 hover:text-red-500 transition-colors" className="text-[var(--text-muted)] hover:text-red-500 transition-colors"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
)} )}
</div> </div>
<p className="text-sm text-gray-600 leading-relaxed">{c.content}</p> <p className="text-sm text-[var(--text-secondary)] leading-relaxed">{c.content}</p>
</div> </div>
<p className="text-[10px] text-gray-400 mt-1 ml-1 font-medium"> <p className="text-[10px] text-[var(--text-muted)] mt-1 ml-1 font-medium">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p> </p>
</div> </div>
@@ -186,7 +186,7 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
</div> </div>
{/* Input Area */} {/* Input Area */}
<div className="p-4 bg-white border-t border-gray-100"> <div className="p-4 bg-[var(--surface)] border-t border-[var(--border)]">
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2">
<input <input
type="text" type="text"
@@ -194,7 +194,7 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
onChange={(e) => setNewComment(e.target.value)} onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()} onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={isPublicView ? 'Đăng nhập để bình luận...' : 'Viết bình luận...'} placeholder={isPublicView ? 'Đăng nhập để bình luận...' : 'Viết bình luận...'}
className="flex-1 bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all" className="flex-1 bg-[var(--background)] border border-[var(--border)] rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] transition-all"
/> />
<button onClick={handleSend} disabled={!newComment.trim() || isPublicView} className="p-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 disabled:opacity-50 disabled:bg-gray-300 transition-all active:scale-95 shadow-lg shadow-blue-100"> <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" /> <Send className="w-4 h-4" />
+6 -6
View File
@@ -18,25 +18,25 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, title, messa
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onCancel} /> <div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onCancel} />
{/* Modal Content */} {/* Modal Content */}
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200"> <div className="relative w-full max-w-sm bg-[var(--surface)] rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-500 shadow-inner"> <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" /> <AlertTriangle className="w-6 h-6" />
</div> </div>
<button onClick={onCancel} className="p-2 hover:bg-gray-100 rounded-full transition-colors"> <button onClick={onCancel} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" /> <X className="w-5 h-5 text-[var(--text-muted)]" />
</button> </button>
</div> </div>
<h3 className="text-xl font-black text-gray-900 mb-2">{title || 'Xác nhận'}</h3> <h3 className="text-xl font-black text-[var(--text-primary)] mb-2">{title || 'Xác nhận'}</h3>
<p className="text-sm text-gray-500 mb-8 leading-relaxed"> <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?'} {message || 'Bạn có chắc chắn muốn thực hiện hành động này không?'}
</p> </p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
onClick={onCancel} onClick={onCancel}
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95" className="py-4 bg-[var(--background)] hover:bg-[var(--background)] text-[var(--text-secondary)] font-bold rounded-2xl transition-all active:scale-95"
> >
Hủy bỏ Hủy bỏ
</button> </button>
+31 -31
View File
@@ -103,18 +103,18 @@ 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 flex flex-col max-h-[90vh]"> <div className="relative w-full max-w-md bg-[var(--surface)] rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50"> <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="p-5 space-y-4 overflow-y-auto flex-1"> <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 text-sm" className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={title} 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"
@@ -122,7 +122,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
</div> </div>
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-2 flex items-center gap-2"> <label className="block text-sm font-bold text-[var(--text-secondary)] mb-2 flex items-center gap-2">
<TagIcon className="w-4 h-4" /> Phân loại Tour <TagIcon className="w-4 h-4" /> Phân loại Tour
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -134,7 +134,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all border ${ className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all border ${
selectedTags.includes(tag) selectedTags.includes(tag)
? 'bg-blue-600 text-white border-blue-600 shadow-md shadow-blue-100' ? 'bg-blue-600 text-white border-blue-600 shadow-md shadow-blue-100'
: 'bg-white text-gray-500 border-gray-200 hover:border-blue-300' : 'bg-[var(--surface)] text-[var(--text-secondary)] border-[var(--border)] hover:border-blue-300'
}`} }`}
> >
{tag} {tag}
@@ -148,7 +148,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
onChange={(e) => setCustomTag(e.target.value)} onChange={(e) => setCustomTag(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustomTag())} onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustomTag())}
placeholder="Thêm nhãn tùy chỉnh..." placeholder="Thêm nhãn tùy chỉnh..."
className="flex-1 px-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs outline-none focus:ring-2 focus:ring-blue-500" className="flex-1 px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-xl text-xs outline-none focus:ring-2 focus:ring-blue-500"
/> />
<button <button
type="button" type="button"
@@ -161,9 +161,9 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
</div> </div>
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1"> tả chuyến đi</label> <label className="block text-sm font-bold text-[var(--text-secondary)] mb-1"> tả chuyến đi</label>
<textarea <textarea
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 resize-none text-sm" className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 resize-none text-sm"
rows={3} rows={3}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
@@ -173,19 +173,19 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<div className="grid grid-cols-2 gap-4"> <div 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 text-sm" className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={startDate} 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 text-sm" className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={endDate} value={endDate}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => setEndDate(e.target.value)}
/> />
@@ -199,18 +199,18 @@ 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>
@@ -218,9 +218,9 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-bold text-gray-700">Thành viên tham gia ({members.length})</label> <label className="block text-sm font-bold text-[var(--text-secondary)]">Thành viên tham gia ({members.length})</label>
{members.length > 0 && ( {members.length > 0 && (
<div className="flex flex-wrap gap-3 mb-3 p-3 bg-gray-50 rounded-2xl border border-gray-100"> <div className="flex flex-wrap gap-3 mb-3 p-3 bg-[var(--background)] rounded-2xl border border-[var(--border)]">
{members.map((m) => { {members.map((m) => {
const initial = m.name?.charAt(0) || '?'; const initial = m.name?.charAt(0) || '?';
return ( return (
@@ -232,13 +232,13 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<button <button
type="button" type="button"
onClick={() => removeMember(m.id)} onClick={() => removeMember(m.id)}
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white" className="absolute -top-1 -right-1 w-4 h-4 bg-gray-400 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
aria-label="Remove item" aria-label="Remove item"
> >
<Trash2 size={10} /> <Trash2 size={10} />
</button> </button>
</div> </div>
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{m.name}</span> <span className="text-[10px] font-semibold text-[var(--text-secondary)] max-w-[72px] truncate">{m.name}</span>
</div> </div>
); );
})} })}
@@ -247,13 +247,13 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<div className="relative"> <div className="relative">
<input <input
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm font-bold text-gray-800" className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm font-bold text-[var(--text-primary)]"
placeholder="Tìm email hoặc nhập tên thành viên ngoài hệ thống..." 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 || query.trim()) && ( {(results.length > 0 || query.trim()) && (
<div className="absolute bottom-full mb-2 left-0 right-0 bg-white border border-gray-100 rounded-2xl shadow-xl z-20 max-h-48 overflow-y-auto p-2 space-y-1"> <div className="absolute bottom-full mb-2 left-0 right-0 bg-[var(--surface)] border border-[var(--border)] rounded-2xl shadow-xl z-20 max-h-48 overflow-y-auto p-2 space-y-1">
{query.trim() && ( {query.trim() && (
<button <button
type="button" type="button"
@@ -263,7 +263,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
setQuery(''); setQuery('');
setResults([]); setResults([]);
}} }}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 rounded-xl text-blue-600 font-bold flex items-center gap-2" className="w-full text-left px-3 py-2 text-sm hover:bg-[var(--background)] rounded-xl text-blue-600 font-bold flex items-center gap-2"
> >
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-600 text-sm font-black">+</span> <span 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> <span>Thêm thành viên ngoài hệ thống: "{query.trim()}"</span>
@@ -274,10 +274,10 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
key={u.id} 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 rounded-xl flex flex-col" className="w-full text-left px-3 py-2 text-sm hover:bg-[var(--background)] rounded-xl flex flex-col"
> >
<span className="font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</span> <span className="font-bold text-[var(--text-primary)]">{u.name || 'Chưa đặt tên'}</span>
<span className="text-xs text-gray-500">{u.email}</span> <span className="text-xs text-[var(--text-muted)]">{u.email}</span>
</button> </button>
))} ))}
</div> </div>
+20 -16
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } 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, MessageSquare, FileText, Flag } from 'lucide-react'; import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag, ChevronDown } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { useConfirm } from '@/hooks/useConfirm'; import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
@@ -57,7 +57,6 @@ export const ItineraryTimeline = ({
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 // Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop
@@ -259,7 +258,22 @@ export const ItineraryTimeline = ({
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-2 ml-4" onClick={(e) => e.stopPropagation()}> {/* Expand/Collapse indicator button */}
<button
onClick={(e) => { e.stopPropagation(); toggleStageExpanded(leg.id); }}
className="ml-2 p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all shrink-0"
title={expandedStageId === leg.id ? 'Collapse chặng này' : 'Expand chặng này'}
>
<ChevronDown
className={`w-5 h-5 transition-transform duration-300 ${
expandedStageId === leg.id ? 'rotate-180' : ''
}`}
/>
</button>
</div>
{/* Action Buttons Row - Below stage title */}
<div className="px-4 py-2 bg-white border-b border-gray-100 flex items-center gap-2 flex-wrap">
{canEdit && ( {canEdit && (
<> <>
<button <button
@@ -284,7 +298,7 @@ export const ItineraryTimeline = ({
</> </>
)} )}
{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>
@@ -294,23 +308,13 @@ export const ItineraryTimeline = ({
</div> </div>
</div> </div>
)} )}
{totalDwellMinutes > 0 && ( // Always show dwell time {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>
)} {/* Only show optimize button if canEdit */}
</div>
{/* Scrollable content body with proper z-index layering */} {/* Scrollable content body with proper z-index layering */}
<div className="child-nodes-list-wrapper"> <div className="child-nodes-list-wrapper">
+12 -12
View File
@@ -209,7 +209,7 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
return ( return (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/50"> <div className="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/50">
<div className="w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200"> <div className="w-full max-w-md bg-[var(--surface)] rounded-[32px] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
{/* Header */} {/* Header */}
<div className="relative h-32 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 overflow-hidden"> <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 opacity-10">
@@ -228,10 +228,10 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
{/* Content */} {/* Content */}
<div className="p-8"> <div className="p-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2 text-center"> <h2 className="text-3xl font-bold text-[var(--text-primary)] mb-2 text-center">
Gia nhập tour Gia nhập tour
</h2> </h2>
<p className="text-center text-gray-600 mb-6"> <p className="text-center text-[var(--text-secondary)] mb-6">
Đăng nhập đ tham gia chuyến du lịch này Đăng nhập đ tham gia chuyến du lịch này
</p> </p>
@@ -248,45 +248,45 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
<div className="relative mb-6"> <div className="relative mb-6">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" /> <div className="w-full border-t border-[var(--border)]" />
</div> </div>
<div className="relative flex justify-center text-sm"> <div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Hoặc</span> <span className="px-2 bg-[var(--surface)] text-[var(--text-muted)]">Hoặc</span>
</div> </div>
</div> </div>
{/* Email/Password Form */} {/* Email/Password Form */}
<form onSubmit={handleEmailPasswordJoin} className="space-y-4"> <form onSubmit={handleEmailPasswordJoin} className="space-y-4">
<div> <div>
<label className="block text-sm font-semibold text-gray-700 mb-2"> <label className="block text-sm font-semibold text-[var(--text-secondary)] mb-2">
Email Email
</label> </label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" /> <Mail className="absolute left-3 top-3.5 w-5 h-5 text-[var(--text-muted)]" />
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com" placeholder="your@email.com"
required required
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" className="w-full pl-10 pr-4 py-2.5 border border-[var(--border)] rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition bg-[var(--background)]"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-semibold text-gray-700 mb-2"> <label className="block text-sm font-semibold text-[var(--text-secondary)] mb-2">
Mật khẩu Mật khẩu
</label> </label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" /> <Lock className="absolute left-3 top-3.5 w-5 h-5 text-[var(--text-muted)]" />
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Nhập mật khẩu" placeholder="Nhập mật khẩu"
required required
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" className="w-full pl-10 pr-4 py-2.5 border border-[var(--border)] rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition bg-[var(--background)]"
/> />
</div> </div>
</div> </div>
@@ -311,7 +311,7 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
</form> </form>
{/* Signup Link */} {/* Signup Link */}
<div className="mt-6 text-center text-sm text-gray-600"> <div className="mt-6 text-center text-sm text-[var(--text-secondary)]">
Chưa tài khoản?{' '} Chưa tài khoản?{' '}
<button <button
onClick={() => { onClick={() => {
+14 -14
View File
@@ -172,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 max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-300"> <div className="relative w-full max-w-md bg-[var(--surface)] rounded-3xl shadow-2xl overflow-hidden max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-300">
<div className="p-8 sm:p-10"> <div className="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>
@@ -195,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">Tài khoản hoặc Email</label> <label className="text-sm font-semibold text-[var(--text-secondary)] ml-1">Tài khoản hoặc Email</label>
<div className="relative group"> <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="text" type="text"
placeholder="admin hoặc email..." 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>
@@ -239,15 +239,15 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
<div className="relative my-6 flex items-center justify-center"> <div className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div> <div className="w-full border-t border-[var(--border)]"></div>
</div> </div>
<span className="relative px-3 bg-white text-xs font-bold text-gray-400 uppercase">Hoặc</span> <span className="relative px-3 bg-[var(--surface)] text-xs font-bold text-[var(--text-muted)] uppercase">Hoặc</span>
</div> </div>
<div id="google-signin-btn-login" className="w-full flex justify-center"></div> <div id="google-signin-btn-login" className="w-full flex justify-center"></div>
<div className="mt-10 pt-8 border-t border-gray-100 text-center"> <div className="mt-10 pt-8 border-t border-[var(--border)] text-center">
<p className="text-gray-500"> <p className="text-[var(--text-secondary)]">
Chưa tài khoản?{' '} Chưa tài khoản?{' '}
<button <button
onClick={() => { onClose(); onSwitchToSignup?.(); }} onClick={() => { onClose(); onSwitchToSignup?.(); }}
@@ -39,13 +39,13 @@ export const NotificationModal: React.FC<NotificationModalProps> = ({
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onConfirm} /> <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>
+13 -3
View File
@@ -3,6 +3,8 @@ import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { CoordinateSelectModal } from './CoordinateSelectModal'; import { CoordinateSelectModal } from './CoordinateSelectModal';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
import { useConfirm } from '../hooks/useConfirm';
import { useNotification } from '../hooks/useNotification';
interface Comment { interface Comment {
id: string; id: string;
@@ -48,6 +50,8 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
onUpdatePhoto onUpdatePhoto
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const confirm = useConfirm();
const notify = useNotification();
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -358,7 +362,12 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
}; };
const handleDeleteComment = async (commentId: string) => { const handleDeleteComment = async (commentId: string) => {
if (!confirm(t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?')) return; const shouldDelete = await confirm({
title: t('deleteComment') || 'Xóa bình luận',
message: t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?'
});
if (!shouldDelete) return;
try { try {
const token = localStorage.getItem('token') || localStorage.getItem('guest_token'); const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
const res = await fetch(`/api/v1/public-photos/comments/${commentId}`, { const res = await fetch(`/api/v1/public-photos/comments/${commentId}`, {
@@ -369,13 +378,14 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
}); });
if (res.ok) { if (res.ok) {
setComments(prev => prev.filter(c => c.id !== commentId)); 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 { } else {
const err = await res.json(); const err = await res.json();
alert(err.message || 'Lỗi khi xóa bình luận.'); notify({ title: 'Lỗi', message: err.message || 'Lỗi khi xóa bình luận.', type: 'error' });
} }
} catch (error) { } catch (error) {
console.error('Lỗi khi xóa bình luận:', error); console.error('Lỗi khi xóa bình luận:', error);
alert('Không thể kết nối đến máy chủ.'); notify({ title: 'Lỗi', message: 'Không thể kết nối đến máy chủ.', type: 'error' });
} }
}; };
+221
View File
@@ -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ẻ 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>
);
};
+114 -17
View File
@@ -1,10 +1,96 @@
@import "tailwindcss"; @import "tailwindcss";
@layer base { @layer base {
/* ============ LIGHT THEME (Default) ============ */
:root { :root {
--main-header-height: 56px; --main-header-height: 56px;
--sub-nav-height: 48px; --sub-nav-height: 48px;
--combined-top-height: 104px; --combined-top-height: 104px;
/* Light Theme Variables - Soft Parchment & Yale Blue */
--background: #f6f0ed; /* Parchment */
--background-alt: #f9fafb; /* Light Gray Alt */
--surface: #ffffff; /* Pure White for crisp card layers */
--surface-muted: #f3f4f6; /* Muted Surface */
--surface-hover: #f9fafb; /* Surface Hover */
--border: #e5e7eb; /* Light Border */
--border-light: #f3f4f6; /* Light Border Alt */
--text-primary: #28536b; /* Yale Blue (High contrast text) */
--text-secondary: #7ea8be; /* Steel Blue */
--text-accent: #c2948a; /* Rosy Taupe */
--text-muted: #9ca3af; /* Muted Text */
--text-disabled: #d1d5db; /* Disabled Text */
--primary: #28536b; /* Yale Blue for main buttons */
--primary-hover: #1f4154; /* Darker Yale Blue */
--primary-light: #eff6ff; /* Light Blue Background */
--secondary: #7ea8be; /* Steel Blue */
--secondary-light: #f0f9ff; /* Light Secondary */
--success: #10b981; /* Green */
--success-light: #ecfdf5;
--danger: #ef4444; /* Red */
--danger-light: #fef2f2;
--warning: #f59e0b; /* Amber */
--warning-light: #fffbeb;
--info: #3b82f6; /* Blue */
--info-light: #eff6ff;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
/* Smooth theme transition */
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
/* ============ DARK THEME ============ */
:root.dark {
/* Dark Theme Variables - Vintage Grape & Bondi Blue */
--background: #513b56; /* Vintage Grape */
--background-alt: #434354; /* Dark Alt */
--surface: #525174; /* Dusty Grape */
--surface-muted: #626186; /* Surface Muted */
--surface-hover: #5f6b7e; /* Surface Hover */
--border: #626186; /* Border */
--border-light: #525174; /* Border Light */
--text-primary: #f8fafc; /* Soft White */
--text-secondary: #cbd5e1; /* Secondary Text */
--text-accent: #bce784; /* Lime Cream */
--text-muted: #94a3b8; /* Muted Slate */
--text-disabled: #64748b; /* Disabled Text */
--primary: #348aa7; /* Bondi Blue */
--primary-hover: #296f86; /* Darker Bondi Blue */
--primary-light: #0c2340; /* Dark Blue Background */
--secondary: #5dd39e; /* Emerald */
--secondary-light: #064e3b; /* Dark Secondary */
--success: #10b981; /* Green */
--success-light: #064e3b;
--danger: #ef4444; /* Red */
--danger-light: #3f0f0f;
--warning: #f59e0b; /* Amber */
--warning-light: #3f2009;
--info: #3b82f6; /* Blue */
--info-light: #0c2340;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
/* Smooth theme transition */
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
} }
html, body, #root, .app-container { html, body, #root, .app-container {
@@ -33,7 +119,8 @@
top: 0; top: 0;
height: var(--main-header-height); height: var(--main-header-height);
z-index: 50; z-index: 50;
background-color: #ffffff; background-color: var(--surface);
color: var(--text-primary);
} }
.sub-nav-menu { .sub-nav-menu {
@@ -45,8 +132,9 @@
margin: 0 !important; margin: 0 !important;
border-radius: 0 !important; border-radius: 0 !important;
box-shadow: none !important; box-shadow: none !important;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid var(--border);
background-color: #ffffff; background-color: var(--surface);
color: var(--text-primary);
} }
/* Master viewport wrapper - contains entire itinerary */ /* Master viewport wrapper - contains entire itinerary */
@@ -71,7 +159,8 @@
position: sticky !important; position: sticky !important;
top: 0; top: 0;
z-index: 100 !important; z-index: 100 !important;
background-color: #ffffff; background-color: var(--surface);
color: var(--text-primary);
flex-shrink: 0 !important; flex-shrink: 0 !important;
} }
@@ -79,8 +168,9 @@
.fixed-top-header-block { .fixed-top-header-block {
flex-shrink: 0 !important; flex-shrink: 0 !important;
width: 100% !important; width: 100% !important;
background-color: #ffffff; background-color: var(--surface);
border-bottom: 1px solid #e5e7eb; color: var(--text-primary);
border-bottom: 1px solid var(--border);
} }
/* Timeline scroll container - main scrollable region */ /* Timeline scroll container - main scrollable region */
@@ -92,7 +182,8 @@
width: 100% !important; width: 100% !important;
padding: 0 !important; padding: 0 !important;
margin: 0 !important; margin: 0 !important;
background-color: #f8fafc; background-color: var(--background);
color: var(--text-primary);
} }
.timeline-scroll-container { .timeline-scroll-container {
@@ -111,7 +202,8 @@
width: 100% !important; width: 100% !important;
padding: 0 !important; padding: 0 !important;
margin-top: 0px !important; margin-top: 0px !important;
background-color: #f8fafc; background-color: var(--background);
color: var(--text-primary);
} }
.timeline-scroll-viewport { .timeline-scroll-viewport {
@@ -134,16 +226,19 @@
/* Individual folder node wrapper - base stage container */ /* Individual folder node wrapper - base stage container */
.folder-node-wrapper { .folder-node-wrapper {
width: 100% !important; width: 100% !important;
background-color: #ffffff; background-color: var(--surface);
margin-bottom: 1px !important; color: var(--text-primary);
margin-bottom: 0px !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-bottom: 1px solid var(--border);
} }
/* Individual stage card block - backwards compatibility */ /* Individual stage card block - backwards compatibility */
.stage-card-block { .stage-card-block {
width: 100% !important; width: 100% !important;
background-color: #ffffff; background-color: var(--surface);
color: var(--text-primary);
margin-bottom: 1px !important; margin-bottom: 1px !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -167,14 +262,15 @@
padding: 12px 16px !important; padding: 12px 16px !important;
display: flex; display: flex;
align-items: center; align-items: center;
background-color: #ffffff !important; background-color: var(--surface) !important;
color: var(--text-primary);
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid var(--border);
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.folder-header-row:hover { .folder-header-row:hover {
background-color: #f9fafb !important; background-color: var(--surface-muted) !important;
} }
/* Stage header row - sticky positioning for pinned effect */ /* Stage header row - sticky positioning for pinned effect */
@@ -186,14 +282,15 @@
padding: 12px 16px !important; padding: 12px 16px !important;
display: flex; display: flex;
align-items: center; align-items: center;
background-color: #ffffff !important; background-color: var(--surface) !important;
color: var(--text-primary);
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid var(--border);
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.stage-header-row:hover { .stage-header-row:hover {
background-color: #f9fafb !important; background-color: var(--surface-muted) !important;
} }
/* Alternate sticky header styling (kept for backwards compatibility) */ /* Alternate sticky header styling (kept for backwards compatibility) */
+140 -61
View File
@@ -24,6 +24,21 @@ const DefaultIcon = L.icon({
}); });
L.Marker.prototype.options.icon = DefaultIcon; L.Marker.prototype.options.icon = DefaultIcon;
// Tag ID to Label Mapping for Public Photos
const PHOTO_TAG_LABELS: { [key: string]: string } = {
'phong-canh': '🏞️ Phong cảnh',
'con-nguoi': '👥 Con người',
'doi-thuong': '🎒 Đời thường',
'bien': '🌊 Biển',
'nui': '⛰️ Núi',
'do-thi': '🏙️ Đô thị',
'thuc-an': '🍜 Thức ăn',
'cho': '🛍️ Chợ',
'hien-dai': '🏗️ Hiện đại',
'dong-vat': '🦁 Động vật',
'thu-cung': '🐕 Thú cưng'
};
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi // Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
function RecenterMap({ position }: { position: [number, number] }) { function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap(); const map = useMap();
@@ -192,10 +207,22 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const [publicPhotos, setPublicPhotos] = useState<any[]>([]); const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null); const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]); const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState<string[]>([]);
const groupedPhotos = React.useMemo(() => { const groupedPhotos = React.useMemo(() => {
// Filter photos by selected tags if any are selected
let filteredPhotos = publicPhotos;
if (selectedPhotoFilterTags.length > 0) {
filteredPhotos = publicPhotos.filter((photo) => {
const photoTags = photo.metadata?.tags as string[] | undefined;
if (!Array.isArray(photoTags)) return false;
// Check if photo has at least one of the selected tags
return selectedPhotoFilterTags.some(tag => photoTags.includes(tag));
});
}
const groups: { [key: string]: any[] } = {}; const groups: { [key: string]: any[] } = {};
publicPhotos.forEach((photo) => { filteredPhotos.forEach((photo) => {
const lat = photo.metadata?.lat; const lat = photo.metadata?.lat;
const lng = photo.metadata?.lng; const lng = photo.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') { if (typeof lat === 'number' && typeof lng === 'number') {
@@ -215,7 +242,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
}); });
}); });
return Object.values(groups); return Object.values(groups);
}, [publicPhotos]); }, [publicPhotos, selectedPhotoFilterTags]);
const fetchPublicPhotos = async () => { const fetchPublicPhotos = async () => {
try { try {
@@ -464,6 +491,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
return Array.from(tagsSet); return Array.from(tagsSet);
}, [publicTours]); }, [publicTours]);
// Tổng hợp nhãn từ danh sách ảnh công khai để lọc ảnh
const availablePhotoTags = React.useMemo(() => {
const tagsSet = new Set<string>();
publicPhotos.forEach(photo => {
const tags = photo.metadata?.tags as string[] | undefined;
if (Array.isArray(tags)) {
tags.forEach(tag => tagsSet.add(tag));
}
});
return Array.from(tagsSet).sort();
}, [publicPhotos]);
// State cho menu chuột phải chia sẻ // State cho menu chuột phải chia sẻ
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean, isParticipant: boolean, hasPendingRequest: boolean } | null>(null); const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean, isParticipant: boolean, hasPendingRequest: boolean } | null>(null);
@@ -616,33 +655,35 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
<div className="flex items-center gap-3 pointer-events-auto"> <div className="flex items-center gap-3 pointer-events-auto">
<button <button
onClick={onBack} onClick={onBack}
className="w-11 h-11 flex items-center justify-center bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 shrink-0" className="w-11 h-11 flex items-center justify-center bg-[var(--surface)] rounded-full shadow-xl hover:bg-[var(--background)] transition-all border border-[var(--border)] shrink-0"
title="Quay lại" title="Quay lại"
> >
<ChevronLeft className="w-6 h-6 text-gray-800" /> <ChevronLeft className="w-6 h-6 text-[var(--text-primary)]" />
</button> </button>
{/* Nút lọc Tag và Dropdown */} {/* Nút lọc Tag và Dropdown */}
<div className="relative"> <div className="relative">
<button <button
onClick={() => setIsFilterDropdownOpen(prev => !prev)} onClick={() => setIsFilterDropdownOpen(prev => !prev)}
className="w-11 h-11 bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center shrink-0" className="w-11 h-11 bg-[var(--surface)] rounded-full shadow-xl hover:bg-[var(--background)] transition-all border border-[var(--border)] flex items-center justify-center shrink-0"
title="Lọc theo loại" title="Lọc theo loại"
> >
<Filter className="w-6 h-6 text-gray-800" /> <Filter className="w-6 h-6 text-[var(--text-primary)]" />
</button> </button>
{/* Filter Dropdown Content */} {/* Filter Dropdown Content */}
{isFilterDropdownOpen && ( {isFilterDropdownOpen && (
<div className="absolute top-full left-0 mt-3 bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 flex flex-col gap-2 max-w-[200px] z-[1003] animate-in slide-in-from-left-2 duration-200"> <div className="absolute top-full left-0 mt-3 bg-[var(--surface)]/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-[var(--surface)]/20 flex flex-col gap-3 max-w-[220px] z-[1003] animate-in slide-in-from-left-2 duration-200 max-h-[400px] overflow-y-auto">
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1"> {/* Tour Filter Section */}
<div>
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--border)] mb-1.5">
<Filter className="w-3.5 h-3.5 text-blue-600" /> <Filter className="w-3.5 h-3.5 text-blue-600" />
<span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span> <span className="text-[10px] font-black uppercase text-[var(--text-muted)] tracking-wider">🧳 Chuyến đi</span>
</div> </div>
<div className="flex flex-col gap-1.5"> {/* Hiển thị tag theo chiều dọc */} <div className="flex flex-col gap-1.5">
<button <button
onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }} onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`} className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
> >
Tất cả Tất cả
</button> </button>
@@ -650,48 +691,86 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
<button <button
key={tag} key={tag}
onClick={() => { setSelectedFilterTag(tag === selectedFilterTag ? null : tag); setIsFilterDropdownOpen(false); }} onClick={() => { setSelectedFilterTag(tag === selectedFilterTag ? null : tag); setIsFilterDropdownOpen(false); }}
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`} className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
> >
{tag} {tag}
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* Photo Filter Section */}
{availablePhotoTags.length > 0 && (
<div className="border-t border-[var(--border)] pt-3">
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--border)] mb-1.5">
<ImageIcon className="w-3.5 h-3.5 text-emerald-600" />
<span className="text-[10px] font-black uppercase text-[var(--text-muted)] tracking-wider">📸 nh công khai</span>
</div>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => { setSelectedPhotoFilterTags([]); setIsFilterDropdownOpen(false); }}
className={`px-2 py-1 rounded-lg text-[9px] font-bold transition-all ${selectedPhotoFilterTags.length === 0 ? 'bg-emerald-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
>
Tất cả
</button>
{availablePhotoTags.map(tagId => {
const tagLabel = PHOTO_TAG_LABELS[tagId] || tagId;
return (
<button
key={tagId}
onClick={() => {
setSelectedPhotoFilterTags(prev =>
prev.includes(tagId)
? prev.filter(t => t !== tagId)
: [...prev, tagId]
);
}}
className={`px-2 py-1 rounded-lg text-[9px] font-bold transition-all ${selectedPhotoFilterTags.includes(tagId) ? 'bg-emerald-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
title={tagLabel}
>
{tagLabel.length > 13 ? tagLabel.substring(0, 13) + '...' : tagLabel}
</button>
);
})}
</div>
</div>
)}
</div>
)} )}
</div> </div>
{/* Search Box - Thay thế div "Khám phá khu vực" */} {/* Search Box - Thay thế div "Khám phá khu vực" */}
<div className="relative flex items-center bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white/20 flex-1 max-w-xs sm:max-w-md pointer-events-auto hidden sm:flex px-4 py-3"> <div className="relative flex items-center bg-[var(--surface)]/90 backdrop-blur-md rounded-2xl shadow-xl border border-[var(--surface)]/20 flex-1 max-w-xs sm:max-w-md pointer-events-auto hidden sm:flex px-4 py-3">
<Navigation className="w-4 h-4 text-blue-600 mr-2 flex-shrink-0" /> <Navigation className="w-4 h-4 text-blue-600 mr-2 flex-shrink-0" />
<input <input
type="text" type="text"
placeholder="Tìm kiếm địa điểm, tour..." placeholder="Tìm kiếm địa điểm, tour..."
className="flex-1 bg-transparent outline-none text-gray-800 text-sm font-medium" className="flex-1 bg-transparent outline-none text-[var(--text-primary)] text-sm font-medium"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
{isSearchingSuggestions && <Loader2 className="w-4 h-4 animate-spin text-blue-500 mr-2" />} {isSearchingSuggestions && <Loader2 className="w-4 h-4 animate-spin text-blue-500 mr-2" />}
{searchQuery && ( {searchQuery && (
<button onClick={() => { setSearchQuery(''); setSuggestions([]); }} className="p-1 text-gray-400 hover:text-gray-600 rounded-full"> <button onClick={() => { setSearchQuery(''); setSuggestions([]); }} className="p-1 text-[var(--text-muted)] hover:text-[var(--text-secondary)] rounded-full">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
)} )}
{/* Dropdown danh sách gợi ý */} {/* Dropdown danh sách gợi ý */}
{suggestions.length > 0 && ( {suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-3 bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl border border-white/20 overflow-hidden z-[1003] animate-in slide-in-from-top-2 duration-200"> <div className="absolute top-full left-0 right-0 mt-3 bg-[var(--surface)]/95 backdrop-blur-md rounded-2xl shadow-2xl border border-[var(--surface)]/20 overflow-hidden z-[1003] animate-in slide-in-from-top-2 duration-200">
{suggestions.map((s, idx) => ( {suggestions.map((s, idx) => (
<button <button
key={`${s.type}-${s.id}-${idx}`} key={`${s.type}-${s.id}-${idx}`}
onClick={() => handleSelectSuggestion(s)} onClick={() => handleSelectSuggestion(s)}
className="w-full text-left px-4 py-3 hover:bg-blue-50 flex items-center gap-3 transition-colors border-b border-gray-50 last:border-0" className="w-full text-left px-4 py-3 hover:bg-[var(--background)] flex items-center gap-3 transition-colors border-b border-[var(--border)] last:border-0"
> >
<div className={`p-2 rounded-xl flex-shrink-0 ${s.type === 'tour' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'}`}> <div className={`p-2 rounded-xl flex-shrink-0 ${s.type === 'tour' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'}`}>
{s.type === 'tour' ? <ImageIcon className="w-4 h-4" /> : <MapPin className="w-4 h-4" />} {s.type === 'tour' ? <ImageIcon className="w-4 h-4" /> : <MapPin className="w-4 h-4" />}
</div> </div>
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<span className="text-sm font-bold text-gray-800 truncate">{s.name}</span> <span className="text-sm font-bold text-[var(--text-primary)] truncate">{s.name}</span>
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider"> <span className="text-[10px] font-black uppercase text-[var(--text-muted)] tracking-wider">
{s.type === 'tour' ? 'Chuyến đi của bạn' : 'Địa điểm trên bản đồ'} {s.type === 'tour' ? 'Chuyến đi của bạn' : 'Địa điểm trên bản đồ'}
</span> </span>
</div> </div>
@@ -711,7 +790,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
console.log("Đang mở Ảnh của tôi..."); console.log("Đang mở Ảnh của tôi...");
onOpenMyPhotos(); onOpenMyPhotos();
}} }}
className="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0" className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
title="Ảnh của tôi" title="Ảnh của tôi"
> >
<ImageIcon className="w-5 h-5" /> <ImageIcon className="w-5 h-5" />
@@ -743,31 +822,31 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{/* Lựa chọn Ngôn ngữ */} {/* Lựa chọn Ngôn ngữ */}
<div className="relative group shrink-0"> <div className="relative group shrink-0">
<button className="w-11 h-11 bg-white dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 transition-all flex items-center justify-center border border-gray-100 dark:border-slate-700"> <button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
<Globe className="w-5 h-5" /> <Globe className="w-5 h-5" />
</button> </button>
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-white dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200"> <div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
<button onClick={() => changeLanguage('vi')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'vi' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>Tiếng Việt</button> <button onClick={() => changeLanguage('vi')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'vi' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>Tiếng Việt</button>
<button onClick={() => changeLanguage('en')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'en' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>English</button> <button onClick={() => changeLanguage('en')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'en' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>English</button>
<button onClick={() => changeLanguage('zh')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'zh' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}></button> <button onClick={() => changeLanguage('zh')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'zh' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}></button>
</div> </div>
</div> </div>
{/* Lựa chọn Giao diện */} {/* Lựa chọn Giao diện */}
<div className="relative group shrink-0"> <div className="relative group shrink-0">
<button className="w-11 h-11 bg-white dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 transition-all flex items-center justify-center border border-gray-100 dark:border-slate-700"> <button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
{theme === 'light' && <Sun className="w-5 h-5 text-amber-500" />} {theme === 'light' && <Sun className="w-5 h-5 text-amber-500" />}
{theme === 'dark' && <Moon className="w-5 h-5 text-indigo-400" />} {theme === 'dark' && <Moon className="w-5 h-5 text-indigo-400" />}
{theme === 'system' && <Laptop className="w-5 h-5 animate-pulse" />} {theme === 'system' && <Laptop className="w-5 h-5 animate-pulse" />}
</button> </button>
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-white dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200"> <div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
<button onClick={() => changeTheme('light')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'light' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}> <button onClick={() => changeTheme('light')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'light' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
<Sun className="w-3.5 h-3.5 text-amber-500" /> {t('themeLight')} <Sun className="w-3.5 h-3.5 text-amber-500" /> {t('themeLight')}
</button> </button>
<button onClick={() => changeTheme('dark')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'dark' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}> <button onClick={() => changeTheme('dark')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'dark' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
<Moon className="w-3.5 h-3.5 text-indigo-400" /> {t('themeDark')} <Moon className="w-3.5 h-3.5 text-indigo-400" /> {t('themeDark')}
</button> </button>
<button onClick={() => changeTheme('system')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'system' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}> <button onClick={() => changeTheme('system')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'system' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
<Laptop className="w-3.5 h-3.5" /> {t('themeSystem')} <Laptop className="w-3.5 h-3.5" /> {t('themeSystem')}
</button> </button>
</div> </div>
@@ -801,7 +880,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{onLogout && ( {onLogout && (
<button <button
onClick={onLogout} onClick={onLogout}
className="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-gray-700 border border-gray-100 shrink-0" className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-[var(--text-secondary)] border border-[var(--border)] shrink-0"
title="Đăng xuất" title="Đăng xuất"
> >
<LogOut className="w-5 h-5" /> <LogOut className="w-5 h-5" />
@@ -917,7 +996,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
className: 'custom-bubble', className: 'custom-bubble',
html: ` html: `
<div class="relative group w-14 h-14"> <div class="relative group w-14 h-14">
<div class="w-14 h-14 rounded-full border-4 ${status.borderClass} shadow-lg overflow-hidden transition-transform group-hover:scale-110 flex items-center justify-center bg-gray-100"> <div class="w-14 h-14 rounded-full border-4 ${status.borderClass} shadow-lg overflow-hidden transition-transform group-hover:scale-110 flex items-center justify-center bg-[var(--background)]">
<img src="${tourImage}" class="w-full h-full object-cover" onerror="this.src='https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=100'"/> <img src="${tourImage}" class="w-full h-full object-cover" onerror="this.src='https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=100'"/>
</div> </div>
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white"> <div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
@@ -940,16 +1019,16 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</div> </div>
)} )}
{tour.description && ( {tour.description && (
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic mb-1.5"> <div className="text-[10px] text-[var(--text-muted)] line-clamp-2 leading-tight italic mb-1.5">
{tour.description} {tour.description}
</div> </div>
)} )}
<div className="flex items-center gap-1.5 pt-1 border-t border-gray-100"> <div className="flex items-center gap-1.5 pt-1 border-t border-[var(--border)]">
<span className={`w-2 h-2 rounded-full ${ <span className={`w-2 h-2 rounded-full ${
status.color === 'green' ? 'bg-emerald-500' : status.color === 'green' ? 'bg-emerald-500' :
status.color === 'red' ? 'bg-rose-500' : 'bg-gray-400' status.color === 'red' ? 'bg-rose-500' : 'bg-[var(--text-muted)]'
}`} /> }`} />
<span className="text-[9px] font-bold text-gray-600">{status.label}</span> <span className="text-[9px] font-bold text-[var(--text-secondary)]">{status.label}</span>
</div> </div>
</div> </div>
</Tooltip> </Tooltip>
@@ -1067,13 +1146,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{item.type === 'RESTAURANT' ? t('businessRestaurant') : item.type === 'HOTEL' ? t('businessHotel') : t('businessHomestay')} {item.type === 'RESTAURANT' ? t('businessRestaurant') : item.type === 'HOTEL' ? t('businessHotel') : t('businessHomestay')}
</div> </div>
{item.address && ( {item.address && (
<div className="text-[10px] text-gray-500 mb-1 font-semibold">{item.address}</div> <div className="text-[10px] text-[var(--text-muted)] mb-1 font-semibold">{item.address}</div>
)} )}
{item.phone && ( {item.phone && (
<div className="text-[9px] text-gray-400">SĐT: {item.phone}</div> <div className="text-[9px] text-[var(--text-muted)]">SĐT: {item.phone}</div>
)} )}
{item.email && ( {item.email && (
<div className="text-[9px] text-gray-400">Email: {item.email}</div> <div className="text-[9px] text-[var(--text-muted)]">Email: {item.email}</div>
)} )}
<div className="text-[10px] text-slate-600 font-medium italic mt-1 pt-1 border-t border-emerald-100 whitespace-pre-line"> <div className="text-[10px] text-slate-600 font-medium italic mt-1 pt-1 border-t border-emerald-100 whitespace-pre-line">
{item.description} {item.description}
@@ -1088,7 +1167,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{/* Context Menu Chia sẻ */} {/* Context Menu Chia sẻ */}
{shareMenu && ( {shareMenu && (
<div <div
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200" className="absolute z-[2000] bg-[var(--surface)] rounded-2xl shadow-2xl border border-[var(--border)] py-2 w-48 animate-in zoom-in-95 duration-200"
style={{ top: shareMenu.y, left: shareMenu.x }} style={{ top: shareMenu.y, left: shareMenu.x }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -1096,19 +1175,19 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
shareMenu.canShare ? ( shareMenu.canShare ? (
<button <button
onClick={() => handleShare(shareMenu.id, shareMenu.title)} onClick={() => handleShare(shareMenu.id, shareMenu.title)}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2 transition-colors" className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-blue-700 flex items-center gap-2 transition-colors"
> >
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết <Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
</button> </button>
) : ( ) : (
<div className="px-4 py-2 text-xs text-gray-400 italic font-bold">Bạn đã gia nhập tour này</div> <div className="px-4 py-2 text-xs text-[var(--text-muted)] italic font-bold">Bạn đã gia nhập tour này</div>
) )
) : shareMenu.hasPendingRequest ? ( ) : shareMenu.hasPendingRequest ? (
<button <button
disabled disabled
className="w-full text-left px-4 py-2 text-sm font-bold text-gray-400 flex items-center gap-2 cursor-not-allowed bg-gray-50/50" className="w-full text-left px-4 py-2 text-sm font-bold text-[var(--text-muted)] flex items-center gap-2 cursor-not-allowed bg-[var(--background)]/50"
> >
<Clock className="w-4 h-4 text-gray-400" /> Đang chờ duyệt... <Clock className="w-4 h-4 text-[var(--text-muted)]" /> Đang chờ duyệt...
</button> </button>
) : ( ) : (
<button <button
@@ -1189,33 +1268,33 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
<div className="absolute bottom-6 left-6 z-[1002] pointer-events-auto flex flex-col items-start gap-2"> <div className="absolute bottom-6 left-6 z-[1002] pointer-events-auto flex flex-col items-start gap-2">
<button <button
onClick={() => setIsLeaderboardOpen(prev => !prev)} onClick={() => setIsLeaderboardOpen(prev => !prev)}
className="bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 text-slate-800 dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95" className="bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 text-[var(--text-primary)] dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-[var(--background)] dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95"
> >
<Users className="w-4 h-4 text-amber-500" /> <Users className="w-4 h-4 text-amber-500" />
<span>{t('trustedMembers')} ({trustedUsers.length})</span> <span>{t('trustedMembers')} ({trustedUsers.length})</span>
</button> </button>
{isLeaderboardOpen && ( {isLeaderboardOpen && (
<div className="bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-gray-100 dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300"> <div className="bg-[var(--surface)]/95 dark:bg-slate-900/95 backdrop-blur-md border border-[var(--border)] dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
<h4 className="text-xs font-black uppercase text-amber-600 dark:text-amber-500 tracking-widest mb-3 flex items-center gap-2"> <h4 className="text-xs font-black uppercase text-amber-600 dark:text-amber-500 tracking-widest mb-3 flex items-center gap-2">
🏆 {t('trustedMembers')} 🏆 {t('trustedMembers')}
</h4> </h4>
{trustedUsers.length === 0 ? ( {trustedUsers.length === 0 ? (
<p className="text-xs text-gray-400 italic">Chưa thành viên nào đưc đánh giá.</p> <p className="text-xs text-[var(--text-muted)] italic">Chưa thành viên nào đưc đánh giá.</p>
) : ( ) : (
<div className="space-y-2.5"> <div className="space-y-2.5">
{trustedUsers.map((u, idx) => ( {trustedUsers.map((u, idx) => (
<div key={u.id} className="flex items-center justify-between gap-3 p-2 bg-gray-50/50 dark:bg-slate-850/50 rounded-xl border border-gray-100/50 dark:border-slate-800/50"> <div key={u.id} className="flex items-center justify-between gap-3 p-2 bg-[var(--background)]/50 dark:bg-slate-850/50 rounded-xl border border-[var(--border)]/50 dark:border-slate-800/50">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 font-bold text-[10px] text-gray-500 flex items-center justify-center bg-gray-100 dark:bg-slate-800 rounded-lg"> <div className="w-5 h-5 font-bold text-[10px] text-[var(--text-muted)] flex items-center justify-center bg-[var(--background)] dark:bg-slate-800 rounded-lg">
{idx + 1} {idx + 1}
</div> </div>
<span className="text-xs font-bold text-gray-800 dark:text-slate-200 truncate">{u.name}</span> <span className="text-xs font-bold text-[var(--text-primary)] dark:text-slate-200 truncate">{u.name}</span>
</div> </div>
<div className="flex items-center gap-1 text-[11px] font-bold text-amber-500 shrink-0"> <div className="flex items-center gap-1 text-[11px] font-bold text-amber-500 shrink-0">
<span></span> <span></span>
<span>{u.averageScore}</span> <span>{u.averageScore}</span>
<span className="text-[9px] text-gray-400 font-medium">({u.ratingCount})</span> <span className="text-[9px] text-[var(--text-muted)] font-medium">({u.ratingCount})</span>
</div> </div>
</div> </div>
))} ))}
@@ -1233,7 +1312,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
setIsRecommendedOpen(prev => !prev); setIsRecommendedOpen(prev => !prev);
setIsBlacklistOpen(false); setIsBlacklistOpen(false);
}} }}
className="bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 text-slate-800 dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95 border-emerald-500/20" className="bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 text-[var(--text-primary)] dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-[var(--background)] dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95 border-emerald-500/20"
> >
<Star className="w-4 h-4 text-emerald-500 fill-emerald-500 animate-pulse" /> <Star className="w-4 h-4 text-emerald-500 fill-emerald-500 animate-pulse" />
<span>{t('recommendedTitle') || 'Đề xuất dịch vụ'} ({recommendedLocations.length})</span> <span>{t('recommendedTitle') || 'Đề xuất dịch vụ'} ({recommendedLocations.length})</span>
@@ -1245,7 +1324,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
setIsBlacklistOpen(prev => !prev); setIsBlacklistOpen(prev => !prev);
setIsRecommendedOpen(false); setIsRecommendedOpen(false);
}} }}
className="bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 text-slate-800 dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95 border-red-500/20" className="bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 text-[var(--text-primary)] dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-[var(--background)] dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95 border-red-500/20"
> >
<ShieldAlert className="w-4 h-4 text-red-500 animate-pulse" /> <ShieldAlert className="w-4 h-4 text-red-500 animate-pulse" />
<span>{t('blacklistTitle') || 'Danh sách đen'} ({blacklist.length})</span> <span>{t('blacklistTitle') || 'Danh sách đen'} ({blacklist.length})</span>
@@ -1253,8 +1332,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{/* Recommendations Panel */} {/* Recommendations Panel */}
{isRecommendedOpen && ( {isRecommendedOpen && (
<div className="bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-gray-100 dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300"> <div className="bg-[var(--surface)]/95 dark:bg-slate-900/95 backdrop-blur-md border border-[var(--border)] dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
<h4 className="text-xs font-black uppercase text-emerald-600 dark:text-emerald-500 tracking-widest mb-3 flex items-center gap-2 border-b border-gray-100 dark:border-slate-800 pb-2"> <h4 className="text-xs font-black uppercase text-emerald-600 dark:text-emerald-500 tracking-widest mb-3 flex items-center gap-2 border-b border-[var(--border)] dark:border-slate-800 pb-2">
🌟 {t('recommendedTitle') || 'Đề xuất chất lượng'} 🌟 {t('recommendedTitle') || 'Đề xuất chất lượng'}
</h4> </h4>
@@ -1284,7 +1363,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</div> </div>
{processedRecommendations.length === 0 ? ( {processedRecommendations.length === 0 ? (
<p className="text-xs text-gray-400 italic text-center py-4">Chưa đa điểm đ xuất nào.</p> <p className="text-xs text-[var(--text-muted)] italic text-center py-4">Chưa đa điểm đ xuất nào.</p>
) : ( ) : (
<div className="space-y-2.5"> <div className="space-y-2.5">
{processedRecommendations.map((item) => { {processedRecommendations.map((item) => {
@@ -1332,7 +1411,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</button> </button>
)} )}
<div className="text-[10px] text-slate-600 dark:text-slate-400 font-medium italic mt-1.5 pt-1.5 border-t border-gray-100 dark:border-slate-800 whitespace-pre-line"> <div className="text-[10px] text-[var(--text-secondary)] dark:text-slate-400 font-medium italic mt-1.5 pt-1.5 border-t border-[var(--border)] dark:border-slate-800 whitespace-pre-line">
{item.description} {item.description}
</div> </div>
</div> </div>
@@ -1345,8 +1424,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{/* Blacklist Panel */} {/* Blacklist Panel */}
{isBlacklistOpen && ( {isBlacklistOpen && (
<div className="bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-gray-100 dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300"> <div className="bg-[var(--surface)]/95 dark:bg-slate-900/95 backdrop-blur-md border border-[var(--border)] dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
<h4 className="text-xs font-black uppercase text-red-600 dark:text-red-500 tracking-widest mb-3 flex items-center gap-2 border-b border-gray-100 dark:border-slate-800 pb-2"> <h4 className="text-xs font-black uppercase text-red-600 dark:text-red-500 tracking-widest mb-3 flex items-center gap-2 border-b border-[var(--border)] dark:border-slate-800 pb-2">
{t('blacklistTitle')} {t('blacklistTitle')}
</h4> </h4>
@@ -1369,7 +1448,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</div> </div>
{processedBlacklist.length === 0 ? ( {processedBlacklist.length === 0 ? (
<p className="text-xs text-gray-400 italic text-center py-4">{t('emptyBlacklist')}</p> <p className="text-xs text-[var(--text-muted)] italic text-center py-4">{t('emptyBlacklist')}</p>
) : ( ) : (
<div className="space-y-2.5"> <div className="space-y-2.5">
{processedBlacklist.map((item) => { {processedBlacklist.map((item) => {
@@ -1409,7 +1488,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
📍 Đnh vị trên bản đ 📍 Đnh vị trên bản đ
</button> </button>
)} )}
<div className="text-[10px] text-red-600 dark:text-red-400 font-medium italic mt-1.5 pt-1.5 border-t border-gray-100 dark:border-slate-850"> <div className="text-[10px] text-red-600 dark:text-red-400 font-medium italic mt-1.5 pt-1.5 border-t border-[var(--border)] dark:border-slate-850">
do: {item.reason} do: {item.reason}
</div> </div>
</div> </div>
+11 -11
View File
@@ -101,8 +101,8 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token'); const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 font-sans"> <div className="min-h-screen flex flex-col items-center justify-center bg-[var(--background)] py-12 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-md w-full space-y-8 bg-white p-10 rounded-3xl shadow-2xl text-center border border-gray-100"> <div className="max-w-md w-full space-y-8 bg-[var(--surface)] p-10 rounded-3xl shadow-2xl text-center border border-[var(--border)]">
{/* Logo/Icon */} {/* Logo/Icon */}
<div className="flex justify-center"> <div className="flex justify-center">
@@ -114,16 +114,16 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
{loading ? ( {loading ? (
<div className="space-y-4 py-6"> <div className="space-y-4 py-6">
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto" /> <Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto" />
<h2 className="text-xl font-bold text-gray-900">Đang xử tham gia hành trình...</h2> <h2 className="text-xl font-bold text-[var(--text-primary)]">Đang xử tham gia hành trình...</h2>
<p className="text-sm text-gray-500">Vui lòng đi trong giây lát.</p> <p className="text-sm text-[var(--text-muted)]">Vui lòng đi trong giây lát.</p>
</div> </div>
) : error ? ( ) : error ? (
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="w-12 h-12 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto"> <div className="w-12 h-12 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto">
<AlertCircle className="w-6 h-6" /> <AlertCircle className="w-6 h-6" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900">Gia nhập thất bại</h2> <h2 className="text-xl font-bold text-[var(--text-primary)]">Gia nhập thất bại</h2>
<p className="text-sm text-red-600 bg-red-50 p-4 rounded-2xl font-semibold border border-red-100">{error}</p> <p className="text-sm text-[var(--text-muted)] bg-red-50 p-4 rounded-2xl font-semibold border border-red-100">{error}</p>
<div className="pt-4 flex flex-col gap-2"> <div className="pt-4 flex flex-col gap-2">
{isLoggedIn ? ( {isLoggedIn ? (
<button <button
@@ -142,7 +142,7 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
</button> </button>
<button <button
onClick={onGoToHome} onClick={onGoToHome}
className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold py-3.5 rounded-2xl transition-all" className="w-full bg-[var(--background)] hover:bg-[var(--background)]/80 text-[var(--text-primary)] font-bold py-3.5 rounded-2xl transition-all border border-[var(--border)]"
> >
Về trang chủ Về trang chủ
</button> </button>
@@ -155,7 +155,7 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
<div className="w-12 h-12 bg-green-50 text-green-500 rounded-full flex items-center justify-center mx-auto"> <div className="w-12 h-12 bg-green-50 text-green-500 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6" /> <CheckCircle className="w-6 h-6" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900">Thành công!</h2> <h2 className="text-xl font-bold text-[var(--text-primary)]">Thành công!</h2>
<p className="text-sm text-green-700 bg-green-50 p-4 rounded-2xl font-semibold border border-green-100">{successMsg}</p> <p className="text-sm text-green-700 bg-green-50 p-4 rounded-2xl font-semibold border border-green-100">{successMsg}</p>
<div className="pt-4"> <div className="pt-4">
<button <button
@@ -171,8 +171,8 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-2xl font-black text-gray-900 tracking-tight">Chào mừng bạn!</h2> <h2 className="text-2xl font-black text-[var(--text-primary)] tracking-tight">Chào mừng bạn!</h2>
<p className="text-gray-600 text-sm leading-relaxed"> <p className="text-[var(--text-secondary)] text-sm leading-relaxed">
Bạn nhận đưc một lời mời tham gia hành trình du lịch. Vui lòng đăng nhập hoặc tạo tài khoản đ thể join xem các hoạt đng, chi phí của tour. Bạn nhận đưc một lời mời tham gia hành trình du lịch. Vui lòng đăng nhập hoặc tạo tài khoản đ thể join xem các hoạt đng, chi phí của tour.
</p> </p>
@@ -185,7 +185,7 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
</button> </button>
<button <button
onClick={onGoToSignup} onClick={onGoToSignup}
className="w-full flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-200 transition-all active:scale-[0.98]" className="w-full flex items-center justify-center gap-2 bg-[var(--background)] hover:bg-[var(--border)] text-[var(--text-primary)] font-bold py-4 rounded-2xl border border-[var(--border)] transition-all active:scale-[0.98]"
> >
<UserPlus className="w-5 h-5" /> Đăng tài khoản mới <UserPlus className="w-5 h-5" /> Đăng tài khoản mới
</button> </button>
+197 -14
View File
@@ -1,7 +1,8 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert } from 'lucide-react'; import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert, Image as ImageIcon, X } from 'lucide-react';
import { LoginModal } from '../components/LoginModal'; import { LoginModal } from '../components/LoginModal';
import { ReportBusinessModal } from '../components/ReportBusinessModal'; import { ReportBusinessModal } from '../components/ReportBusinessModal';
import { TagSelectModal } from '../components/TagSelectModal';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
import { processImageModeration } from '../hooks/useImageModeration'; import { processImageModeration } from '../hooks/useImageModeration';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
@@ -18,7 +19,15 @@ interface LandingPageProps {
export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => { export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isPhotoSourceModalOpen, setIsPhotoSourceModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null);
const [pendingPhotoFile, setPendingPhotoFile] = useState<File | null>(null);
const [pendingPhotoLocation, setPendingPhotoLocation] = useState<GeolocationPosition | null>(null);
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
const notify = useNotification(); const notify = useNotification();
const { t, lang, changeLanguage } = useTranslation(); const { t, lang, changeLanguage } = useTranslation();
const { theme, changeTheme } = useTheme(); const { theme, changeTheme } = useTheme();
@@ -26,7 +35,6 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
const [publicPhotos, setPublicPhotos] = useState<any[]>([]); const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [trustedUsers, setTrustedUsers] = useState<any[]>([]); const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
const [blacklist, setBlacklist] = useState<any[]>([]); const [blacklist, setBlacklist] = useState<any[]>([]);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [currentBgIndex, setCurrentBgIndex] = useState(0); const [currentBgIndex, setCurrentBgIndex] = useState(0);
const [bg1, setBg1] = useState('/background.avif'); const [bg1, setBg1] = useState('/background.avif');
const [bg2, setBg2] = useState(''); const [bg2, setBg2] = useState('');
@@ -97,6 +105,52 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
} }
}; };
// Update Open Graph meta tags when public photos are fetched
useEffect(() => {
if (publicPhotos.length > 0) {
const mainPhoto = publicPhotos[0];
const photoUrl = mainPhoto.imageUrl || mainPhoto.originalUrl || '/background.avif';
const photoTitle = mainPhoto.metadata?.title || 'Travel Planner - Khám phá chuyến đi tuyệt vời';
const photoDescription = mainPhoto.metadata?.description || `Được chia sẻ bởi ${mainPhoto.uploader?.name || 'một thành viên'}. Khám phá những hành trình tuyệt vời trên Travel Planner.`;
// Update og:image
let ogImage = document.querySelector('meta[property="og:image"]');
if (!ogImage) {
ogImage = document.createElement('meta');
ogImage.setAttribute('property', 'og:image');
document.head.appendChild(ogImage);
}
ogImage.setAttribute('content', `https://yotrip.labz.io.vn${photoUrl}`);
// Update og:title
let ogTitle = document.querySelector('meta[property="og:title"]');
if (!ogTitle) {
ogTitle = document.createElement('meta');
ogTitle.setAttribute('property', 'og:title');
document.head.appendChild(ogTitle);
}
ogTitle.setAttribute('content', photoTitle);
// Update og:description
let ogDescription = document.querySelector('meta[property="og:description"]');
if (!ogDescription) {
ogDescription = document.createElement('meta');
ogDescription.setAttribute('property', 'og:description');
document.head.appendChild(ogDescription);
}
ogDescription.setAttribute('content', photoDescription);
// Update twitter:image
let twitterImage = document.querySelector('meta[name="twitter:image"]');
if (!twitterImage) {
twitterImage = document.createElement('meta');
twitterImage.setAttribute('name', 'twitter:image');
document.head.appendChild(twitterImage);
}
twitterImage.setAttribute('content', `https://yotrip.labz.io.vn${photoUrl}`);
}
}, [publicPhotos]);
useEffect(() => { useEffect(() => {
fetchPublicPhotos(); fetchPublicPhotos();
fetchTrustedUsers(); fetchTrustedUsers();
@@ -144,7 +198,31 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500)) new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
]); ]);
// 1. Xóa token cũ nếu có để đảm bảo guest không bị nhầm lẫn // Lưu file và location vào state pending, hiển thị modal tags
setPendingPhotoFile(processedFile);
setPendingPhotoLocation(location);
// Tạo preview URL cho ảnh
const previewUrl = URL.createObjectURL(processedFile);
setPhotoPreviewUrl(previewUrl);
setIsTagsModalOpen(true);
} catch (error: any) {
notify({ title: 'Lỗi', message: error.message, type: 'error' });
} finally {
// Reset input để có thể chọn lại cùng 1 file
if (event.target) event.target.value = '';
}
};
const handleConfirmTags = async (selectedTags: string[]) => {
if (!pendingPhotoFile) return;
setIsTagsModalOpen(false);
notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try {
// 1. Xóa token cũ nếu có để đảm bảo guest không bị nhầm lẫn
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
@@ -162,12 +240,16 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
localStorage.setItem('guest_user', JSON.stringify(guestUser)); localStorage.setItem('guest_user', JSON.stringify(guestUser));
} }
// 2. Tải ảnh lên // 3. Tải ảnh lên
const formData = new FormData(); const formData = new FormData();
formData.append('images', processedFile); formData.append('images', pendingPhotoFile);
if (location) { if (pendingPhotoLocation) {
formData.append('latitude', location.coords.latitude.toString()); formData.append('latitude', pendingPhotoLocation.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString()); formData.append('longitude', pendingPhotoLocation.coords.longitude.toString());
}
// Thêm tags vào formData
if (selectedTags.length > 0) {
formData.append('tags', JSON.stringify(selectedTags));
} }
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', { let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
@@ -203,7 +285,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
throw new Error(errorData.message || 'Tải ảnh thất bại.'); throw new Error(errorData.message || 'Tải ảnh thất bại.');
} }
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' }); notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
// Xóa pendingInviteToken và parameter token khỏi URL để tránh tự động chuyển sang JoinTourPage // Xóa pendingInviteToken và parameter token khỏi URL để tránh tự động chuyển sang JoinTourPage
localStorage.removeItem('pendingInviteToken'); localStorage.removeItem('pendingInviteToken');
@@ -215,17 +297,28 @@ notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải
await fetchPublicPhotos(); await fetchPublicPhotos();
setCurrentBgIndex(0); setCurrentBgIndex(0);
// Xóa pending data
setPendingPhotoFile(null);
setPendingPhotoLocation(null);
if (photoPreviewUrl) {
URL.revokeObjectURL(photoPreviewUrl);
setPhotoPreviewUrl('');
}
} catch (error: any) { } catch (error: any) {
notify({ title: 'Lỗi', message: error.message, type: 'error' }); notify({ title: 'Lỗi', message: error.message, type: 'error' });
} finally { // Cleanup on error too
// Reset input để có thể chọn lại cùng 1 file setPendingPhotoFile(null);
if (event.target) event.target.value = ''; setPendingPhotoLocation(null);
if (photoPreviewUrl) {
URL.revokeObjectURL(photoPreviewUrl);
setPhotoPreviewUrl('');
}
} }
}; };
return ( return (
<div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative"> <div className="h-dvh w-full overflow-hidden font-sans bg-[var(--background)] relative">
{/* Background Image with Horizontal Panning */} {/* Background Image with Horizontal Panning */}
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
{/* Active image for panning */} {/* Active image for panning */}
@@ -468,6 +561,25 @@ return (
className="hidden" className="hidden"
/> />
{/* Camera input - capture="environment" for rear camera */}
<input
type="file"
ref={cameraInputRef}
onChange={handleFileChange}
accept="image/*"
capture="environment"
className="hidden"
/>
{/* Gallery input - no capture attribute for file picker */}
<input
type="file"
ref={galleryInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
{/* Unified Bottom Container: Gallery thumbnails on top, action buttons below */} {/* Unified Bottom Container: Gallery thumbnails on top, action buttons below */}
<div className="absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4 flex flex-col items-center gap-4 max-w-md"> <div className="absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4 flex flex-col items-center gap-4 max-w-md">
{/* Community Gallery Previews */} {/* Community Gallery Previews */}
@@ -517,7 +629,7 @@ return (
</button> </button>
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => setIsPhotoSourceModalOpen(true)}
className="flex-1 flex items-center justify-center gap-2 bg-white/20 backdrop-blur-lg hover:bg-white/30 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-white/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap" className="flex-1 flex items-center justify-center gap-2 bg-white/20 backdrop-blur-lg hover:bg-white/30 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-white/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
> >
<Camera className="w-4.5 h-4.5" /> <Camera className="w-4.5 h-4.5" />
@@ -549,6 +661,77 @@ return (
isOpen={isReportModalOpen} isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)} onClose={() => setIsReportModalOpen(false)}
/> />
{/* Tag Select Modal */}
<TagSelectModal
isOpen={isTagsModalOpen}
onClose={() => {
setIsTagsModalOpen(false);
setPendingPhotoFile(null);
setPendingPhotoLocation(null);
if (photoPreviewUrl) {
URL.revokeObjectURL(photoPreviewUrl);
setPhotoPreviewUrl('');
}
}}
onConfirm={handleConfirmTags}
photoUrl={photoPreviewUrl}
/>
{/* Photo Source Selection Modal */}
{isPhotoSourceModalOpen && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-[var(--surface)] rounded-3xl shadow-2xl max-w-sm w-full overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-500 px-6 py-6 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">Chụp hoặc tải nh</h2>
<button
onClick={() => setIsPhotoSourceModalOpen(false)}
className="p-1.5 hover:bg-white/20 rounded-full transition-colors"
>
<X className="w-5 h-5 text-white" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-3">
{/* Camera Button */}
<button
onClick={() => {
setIsPhotoSourceModalOpen(false);
cameraInputRef.current?.click();
}}
className="w-full flex items-center gap-4 p-4 bg-[var(--background)] hover:bg-[var(--background-alt)] border border-[var(--border)] rounded-2xl transition-all active:scale-95"
>
<div className="flex-shrink-0 p-3 bg-blue-100 rounded-full">
<Camera className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1 text-left">
<div className="font-bold text-[var(--text-primary)]">Chụp nh bằng camera</div>
<div className="text-sm text-[var(--text-muted)]">Dùng camera thiết bị của bạn</div>
</div>
</button>
{/* Gallery Button */}
<button
onClick={() => {
setIsPhotoSourceModalOpen(false);
galleryInputRef.current?.click();
}}
className="w-full flex items-center gap-4 p-4 bg-[var(--background)] hover:bg-[var(--background-alt)] border border-[var(--border)] rounded-2xl transition-all active:scale-95"
>
<div className="flex-shrink-0 p-3 bg-emerald-100 rounded-full">
<ImageIcon className="w-6 h-6 text-emerald-600" />
</div>
<div className="flex-1 text-left">
<div className="font-bold text-[var(--text-primary)]">Tải nh từ thư viện</div>
<div className="text-sm text-[var(--text-muted)]">Chọn nh từ thiết bị của bạn</div>
</div>
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
+172 -16
View File
@@ -22,7 +22,8 @@ import {
Loader2, Loader2,
Bell, Bell,
BellOff, BellOff,
ShieldAlert ShieldAlert,
Camera
} from 'lucide-react'; } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
@@ -128,6 +129,56 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
} }
}; };
const handleEmergencyShare = async (tour: any) => {
try {
setLoadingShare(true);
// Get current location
const position = await new Promise<GeolocationCoordinates>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos.coords),
(err) => reject(err),
{ enableHighAccuracy: true, timeout: 5000 }
);
});
// Send location message to tour chat
const token = localStorage.getItem('token');
const message = `📍 Vị trí hiện tại: ${position.latitude.toFixed(6)}, ${position.longitude.toFixed(6)}\n🔗 Google Maps: https://maps.google.com/?q=${position.latitude},${position.longitude}`;
const res = await fetch(`/api/v1/tours/${tour.id}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ content: message })
});
if (res.ok) {
notify({
title: 'Thành công',
message: 'Đã gửi vị trí hiện tại cho nhóm',
type: 'success'
});
} else {
notify({
title: 'Lỗi',
message: 'Không thể gửi vị trí',
type: 'error'
});
}
} catch (e) {
console.error('Error sharing location:', e);
notify({
title: 'Lỗi',
message: 'Không thể lấy vị trí hiện tại',
type: 'error'
});
} finally {
setLoadingShare(false);
}
};
const handleToggleShare = async (isEnabled: boolean) => { const handleToggleShare = async (isEnabled: boolean) => {
if (!sharingTour) return; if (!sharingTour) return;
try { try {
@@ -154,7 +205,81 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
} }
}; };
const handleTakeCameraPhoto = async (tour: any) => {
if (cameraInputRef.current) {
cameraInputRef.current.click();
// Store tour ID for later processing
(cameraInputRef.current as any).dataset.tourId = tour.id;
}
};
const handleCameraFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const tourId = (event.target as any).dataset.tourId;
// Get current location if available
try {
setIsLocating(true);
const position = await new Promise<GeolocationCoordinates>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos.coords),
(err) => reject(err),
{ enableHighAccuracy: true, timeout: 5000 }
);
});
setAttachedLocation({ latitude: position.latitude, longitude: position.longitude });
} catch (e) {
console.log('Could not get location:', e);
}
setIsLocating(false);
// Upload photo to tour
setIsUploading(true);
try {
const token = localStorage.getItem('token');
const formData = new FormData();
formData.append('images', file);
if (attachedLocation) {
formData.append('latitude', attachedLocation.latitude.toString());
formData.append('longitude', attachedLocation.longitude.toString());
}
const res = await fetch(`/api/v1/tours/${tourId}/photos`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
if (res.ok) {
notify({
title: 'Thành công',
message: 'Đã upload ảnh vào thư viện tour',
type: 'success'
});
// Clear input
if (cameraInputRef.current) cameraInputRef.current.value = '';
} else {
notify({
title: 'Lỗi',
message: 'Không thể upload ảnh',
type: 'error'
});
}
} catch (e) {
console.error('Upload error:', e);
notify({
title: 'Lỗi',
message: 'Lỗi upload ảnh',
type: 'error'
});
} finally {
setIsUploading(false);
}
}
};
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
@@ -1300,22 +1425,14 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
</span> </span>
</div> </div>
<div className="flex flex-col gap-2 mt-2"> <div className="flex flex-col gap-2 mt-2">
<div className="flex gap-2"> {/* Chat button - moved to top */}
<button
onClick={() => onViewTour(tour.id, 'dashboard')}
className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-350 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1"
>
<span>Xem chi tiết</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
<button <button
onClick={() => { onClick={() => {
localStorage.setItem('tour_detail_default_tab', 'chat'); localStorage.setItem('tour_detail_default_tab', 'chat');
setUnreadTourChats(prev => prev.filter(id => id !== tour.id)); setUnreadTourChats(prev => prev.filter(id => id !== tour.id));
onViewTour(tour.id, 'dashboard'); onViewTour(tour.id, 'dashboard');
}} }}
className="flex-1 py-2 px-2 bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-indigo-500/30 hover:border-indigo-500 flex items-center justify-center gap-1 relative whitespace-nowrap" className="w-full py-2 px-2 bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-indigo-500/30 hover:border-indigo-500 flex items-center justify-center gap-1 relative whitespace-nowrap"
> >
<MessageSquare className="w-3.5 h-3.5" /> <MessageSquare className="w-3.5 h-3.5" />
<span>Trò chuyện</span> <span>Trò chuyện</span>
@@ -1326,16 +1443,55 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full border border-slate-800" /> <span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full border border-slate-800" />
)} )}
</button> </button>
</div>
{/* Camera + Detail buttons row */}
<div className="flex gap-2">
<button
onClick={() => handleTakeCameraPhoto(tour)}
disabled={isUploading}
className="flex-1 py-2 px-2 bg-green-600/20 hover:bg-green-600 text-green-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-green-500/30 hover:border-green-500 flex items-center justify-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Camera className="w-3.5 h-3.5" />
)}
<span>Chụp nh</span>
</button>
<button <button
onClick={() => handleOpenShareModal(tour)} onClick={() => onViewTour(tour.id, 'dashboard')}
className="w-full py-2 px-3 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20 hover:border-rose-500 flex items-center justify-center gap-1.5" className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-350 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1"
> >
<ShieldAlert className="w-3.5 h-3.5 text-rose-500" /> <span>Chi tiết hành trình</span>
<span>Chia sẻ khẩn cấp</span> <ChevronRight className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
{/* Emergency Share Button - moved to bottom */}
<button
onClick={() => handleEmergencyShare(tour)}
disabled={loadingShare}
className="w-full py-2 px-3 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20 hover:border-rose-500 flex items-center justify-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingShare ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<ShieldAlert className="w-3.5 h-3.5 text-rose-500" />
)}
<span>Chia sẻ vị trí khẩn cấp</span>
</button>
</div>
{/* Hidden camera input */}
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleCameraFileSelect}
className="hidden"
/>
</div> </div>
</div> </div>
</div> </div>
+19 -19
View File
@@ -339,15 +339,15 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
}); });
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col"> <div className="min-h-screen bg-[var(--background)] flex flex-col">
{/* Header */} {/* Header */}
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-4 flex items-center gap-4"> <div className="sticky top-0 z-30 bg-[var(--surface)]/80 backdrop-blur-md border-b border-[var(--border)] px-4 py-4 flex items-center gap-4">
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors"> <button onClick={onBack} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" /> <ChevronLeft className="w-6 h-6 text-[var(--text-secondary)]" />
</button> </button>
<div> <div>
<h1 className="text-xl font-black text-gray-900">Ghi chú của tôi</h1> <h1 className="text-xl font-black text-[var(--text-primary)]">Ghi chú của tôi</h1>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Sổ tay hành trình nhân</p> <p className="text-[10px] font-bold text-[var(--text-muted)] uppercase tracking-widest">Sổ tay hành trình nhân</p>
</div> </div>
</div> </div>
@@ -358,11 +358,11 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
<input <input
type="text" type="text"
placeholder="Tìm kiếm nội dung ghi chú..." placeholder="Tìm kiếm nội dung ghi chú..."
className="w-full pl-10 pr-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm" className="w-full pl-10 pr-4 py-3 bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
</div> </div>
<button <button
onClick={() => setIsCreating(true)} onClick={() => setIsCreating(true)}
@@ -374,7 +374,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
)} )}
{isLoading ? ( {isLoading ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400"> <div className="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
<Loader2 className="w-10 h-10 animate-spin mb-4" /> <Loader2 className="w-10 h-10 animate-spin mb-4" />
<p className="font-bold">Đang tải ghi chú...</p> <p className="font-bold">Đang tải ghi chú...</p>
</div> </div>
@@ -383,11 +383,11 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
<input <input
type="text" type="text"
placeholder="Tiêu đề ghi chú..." placeholder="Tiêu đề ghi chú..."
className="w-full px-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-lg font-bold" className="w-full px-4 py-3 bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-lg font-bold"
value={noteForm.title} value={noteForm.title}
onChange={(e) => setNoteForm({ ...noteForm, title: e.target.value })} onChange={(e) => setNoteForm({ ...noteForm, title: e.target.value })}
/> />
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden min-h-[500px] flex flex-col"> <div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm overflow-hidden min-h-[500px] flex flex-col">
<ReactQuill <ReactQuill
ref={quillRef} ref={quillRef}
theme="snow" theme="snow"
@@ -395,7 +395,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
onChange={(content) => setNoteForm({ ...noteForm, content })} onChange={(content) => setNoteForm({ ...noteForm, content })}
modules={quillModules} modules={quillModules}
placeholder="Bắt đầu viết cảm nhận của bạn tại đây..." placeholder="Bắt đầu viết cảm nhận của bạn tại đây..."
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-editor]:flex-1 [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-gray-50 [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-gray-200 [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-gray-100 [&_.ql-stroke]:!stroke-gray-500 [&_.ql-fill]:!fill-gray-500 [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500" className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-editor]:flex-1 [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-[var(--border)] [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-[var(--border)] [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-[var(--border)] [&_.ql-stroke]:!stroke-[var(--text-secondary)] [&_.ql-fill]:!fill-[var(--text-secondary)] [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
/> />
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -416,11 +416,11 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
) : filteredNotes.length > 0 ? ( ) : filteredNotes.length > 0 ? (
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
{filteredNotes.map((note) => ( {filteredNotes.map((note) => (
<div key={note.id} className="bg-white p-5 rounded-3xl border border-gray-100 shadow-sm hover:shadow-md transition-all group"> <div key={note.id} className="bg-[var(--surface)] p-5 rounded-3xl border border-[var(--border)] shadow-sm hover:shadow-md transition-all group">
<div className="flex justify-between items-start mb-3"> <div className="flex justify-between items-start mb-3">
<div> <div>
<h4 className="font-bold text-gray-900">{note.title}</h4> <h4 className="font-bold text-[var(--text-primary)]">{note.title}</h4>
<div className="flex items-center gap-2 text-[10px] text-gray-400 font-bold uppercase mt-1"> <div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] font-bold uppercase mt-1">
<Calendar className="w-3 h-3" /> <Calendar className="w-3 h-3" />
{new Date(note.createdAt).toLocaleDateString('vi-VN')} {new Date(note.createdAt).toLocaleDateString('vi-VN')}
</div> </div>
@@ -428,14 +428,14 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
onClick={() => handleEditNote(note)} onClick={() => handleEditNote(note)}
className="p-2 text-gray-300 hover:text-blue-500 hover:bg-blue-50 rounded-xl transition-all opacity-0 group-hover:opacity-100" className="p-2 text-[var(--text-muted)] hover:text-blue-500 hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
title="Sửa ghi chú" title="Sửa ghi chú"
> >
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => handleDeleteNote(note.id)} onClick={() => handleDeleteNote(note.id)}
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all opacity-0 group-hover:opacity-100" className="p-2 text-[var(--text-muted)] hover:text-red-500 hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
title="Xóa ghi chú" title="Xóa ghi chú"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
@@ -443,14 +443,14 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
</div> </div>
</div> </div>
<div <div
className="text-sm text-gray-600 line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_td]:border [&_td]:border-gray-200 [&_td]:p-2 [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify" className="text-sm text-[var(--text-secondary)] line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_td]:border [&_td]:border-[var(--border)] [&_td]:p-2 [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify"
dangerouslySetInnerHTML={{ __html: note.content }} dangerouslySetInnerHTML={{ __html: note.content }}
/> />
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="text-center py-24 bg-white rounded-[40px] border-2 border-dashed border-gray-100 shadow-inner"> <div className="text-center py-24 bg-[var(--surface)] rounded-[40px] border-2 border-dashed border-[var(--border)] shadow-inner">
<div className="w-20 h-20 bg-amber-50 rounded-3xl flex items-center justify-center mx-auto mb-6 text-amber-500"> <div className="w-20 h-20 bg-amber-50 rounded-3xl flex items-center justify-center mx-auto mb-6 text-amber-500">
<FileText className="w-10 h-10" /> <FileText className="w-10 h-10" />
</div> </div>
+1 -1
View File
@@ -358,7 +358,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
title={isLiked ? "Bỏ thích" : "Thích"} title={isLiked ? "Bỏ thích" : "Thích"}
> >
<Heart className={`w-4 h-4 transition-colors ${ <Heart className={`w-4 h-4 transition-colors ${
isLiked ? 'text-rose-500 fill-rose-500' : 'text-gray-300' isLiked ? 'text-rose-500 fill-rose-500' : 'text-[var(--text-muted)]'
}`} /> }`} />
<span>{likeCount}</span> <span>{likeCount}</span>
</button> </button>
+29 -29
View File
@@ -239,7 +239,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
if (error || !tour) { if (error || !tour) {
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 p-4 transition-colors"> <div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 p-4 transition-colors">
<div className="bg-white dark:bg-slate-900 border border-gray-100 dark:border-slate-800 shadow-2xl rounded-[32px] p-8 max-w-md w-full text-center flex flex-col items-center gap-5"> <div className="bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 shadow-2xl rounded-[32px] p-8 max-w-md w-full text-center flex flex-col items-center gap-5">
<div className="w-16 h-16 bg-rose-50 dark:bg-rose-950/20 border border-rose-100 dark:border-rose-900/30 rounded-full flex items-center justify-center text-rose-500"> <div className="w-16 h-16 bg-rose-50 dark:bg-rose-950/20 border border-rose-100 dark:border-rose-900/30 rounded-full flex items-center justify-center text-rose-500">
<ShieldAlert className="w-8 h-8" /> <ShieldAlert className="w-8 h-8" />
</div> </div>
@@ -261,11 +261,11 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
return ( return (
<div className="h-screen w-screen flex flex-col bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 transition-colors overflow-hidden"> <div className="h-screen w-screen flex flex-col bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 transition-colors overflow-hidden">
{/* Header */} {/* Header */}
<header className="h-16 shrink-0 bg-white/70 dark:bg-slate-900/70 backdrop-blur-md border-b border-gray-100 dark:border-slate-800 px-4 md:px-6 flex items-center justify-between z-20"> <header className="h-16 shrink-0 bg-[var(--surface)]/70 dark:bg-slate-900/70 backdrop-blur-md border-b border-[var(--border)] dark:border-slate-800 px-4 md:px-6 flex items-center justify-between z-20">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={onGoToHome} onClick={onGoToHome}
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-xl transition-all active:scale-95 text-slate-500 dark:text-slate-400" className="p-2 hover:bg-[var(--background)] dark:hover:bg-slate-800 rounded-xl transition-all active:scale-95 text-[var(--text-muted)] dark:text-slate-400"
> >
<ChevronLeft className="w-5 h-5" /> <ChevronLeft className="w-5 h-5" />
</button> </button>
@@ -274,7 +274,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
<span className="w-2.5 h-2.5 bg-rose-500 rounded-full animate-pulse"></span> <span className="w-2.5 h-2.5 bg-rose-500 rounded-full animate-pulse"></span>
<h1 className="text-sm md:text-base font-black uppercase tracking-tight">{t('emergencyJourney')}</h1> <h1 className="text-sm md:text-base font-black uppercase tracking-tight">{t('emergencyJourney')}</h1>
</div> </div>
<p className="text-[10px] text-slate-500 dark:text-slate-400 truncate max-w-[200px] sm:max-w-xs">{tour.title}</p> <p className="text-[10px] text-[var(--text-muted)] dark:text-slate-400 truncate max-w-[200px] sm:max-w-xs">{tour.title}</p>
</div> </div>
</div> </div>
@@ -283,7 +283,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
<select <select
value={lang} value={lang}
onChange={(e) => changeLanguage(e.target.value as any)} onChange={(e) => changeLanguage(e.target.value as any)}
className="bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 text-slate-800 dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer" className="bg-[var(--background)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 text-[var(--text-primary)] dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
> >
<option value="vi">VI</option> <option value="vi">VI</option>
<option value="en">EN</option> <option value="en">EN</option>
@@ -294,7 +294,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
<select <select
value={theme} value={theme}
onChange={(e) => changeTheme(e.target.value as any)} onChange={(e) => changeTheme(e.target.value as any)}
className="bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 text-slate-800 dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer" className="bg-[var(--background)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 text-[var(--text-primary)] dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
> >
<option value="light">{t('themeLight')}</option> <option value="light">{t('themeLight')}</option>
<option value="dark">{t('themeDark')}</option> <option value="dark">{t('themeDark')}</option>
@@ -304,7 +304,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
</header> </header>
{/* Mobile Tab Switcher */} {/* Mobile Tab Switcher */}
<div className="lg:hidden h-12 shrink-0 bg-white dark:bg-slate-900 border-b border-gray-100 dark:border-slate-800 flex items-center z-10 p-1"> <div className="lg:hidden h-12 shrink-0 bg-[var(--surface)] dark:bg-slate-900 border-b border-[var(--border)] dark:border-slate-800 flex items-center z-10 p-1">
<button <button
onClick={() => setActiveTab('map')} onClick={() => setActiveTab('map')}
className={`flex-1 h-full flex items-center justify-center gap-2 text-xs font-bold rounded-lg transition-all ${activeTab === 'map' ? 'bg-rose-500 text-white shadow-md' : 'text-slate-500 dark:text-slate-400'}`} className={`flex-1 h-full flex items-center justify-center gap-2 text-xs font-bold rounded-lg transition-all ${activeTab === 'map' ? 'bg-rose-500 text-white shadow-md' : 'text-slate-500 dark:text-slate-400'}`}
@@ -324,13 +324,13 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
{/* Main Layout Area */} {/* Main Layout Area */}
<div className="flex-1 flex flex-col lg:grid lg:grid-cols-12 overflow-hidden"> <div className="flex-1 flex flex-col lg:grid lg:grid-cols-12 overflow-hidden">
{/* Left Side Pane (Itinerary and Contacts) */} {/* Left Side Pane (Itinerary and Contacts) */}
<div className={`flex-col lg:col-span-4 bg-white dark:bg-slate-900 border-r border-gray-100 dark:border-slate-800 overflow-y-auto no-scrollbar ${activeTab === 'itinerary' ? 'flex h-full' : 'hidden lg:flex'}`}> <div className={`flex-col lg:col-span-4 bg-[var(--surface)] dark:bg-slate-900 border-r border-[var(--border)] dark:border-slate-800 overflow-y-auto no-scrollbar ${activeTab === 'itinerary' ? 'flex h-full' : 'hidden lg:flex'}`}>
<div className="p-4 md:p-6 space-y-6"> <div className="p-4 md:p-6 space-y-6">
{/* Tour Meta Info */} {/* Tour Meta Info */}
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-lg md:text-xl font-black text-slate-900 dark:text-white leading-tight">{tour.title}</h2> <h2 className="text-lg md:text-xl font-black text-[var(--text-primary)] dark:text-white leading-tight">{tour.title}</h2>
{tour.startDate && ( {tour.startDate && (
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400"> <div className="flex items-center gap-2 text-xs text-[var(--text-muted)] dark:text-slate-400">
<Calendar className="w-4 h-4 text-rose-500" /> <Calendar className="w-4 h-4 text-rose-500" />
<span> <span>
{new Date(tour.startDate).toLocaleDateString()} {new Date(tour.startDate).toLocaleDateString()}
@@ -339,7 +339,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
</div> </div>
)} )}
{tour.description && ( {tour.description && (
<p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed bg-slate-50 dark:bg-slate-950 p-3 rounded-2xl border border-gray-100 dark:border-slate-800/40"> <p className="text-xs text-[var(--text-secondary)] dark:text-slate-400 leading-relaxed bg-[var(--background)] dark:bg-slate-950 p-3 rounded-2xl border border-[var(--border)] dark:border-slate-800/40">
{tour.description} {tour.description}
</p> </p>
)} )}
@@ -358,8 +358,8 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
<div className="bg-rose-50/50 dark:bg-rose-950/10 border border-rose-100/50 dark:border-rose-950/20 p-4 rounded-2xl flex items-center justify-between gap-4"> <div className="bg-rose-50/50 dark:bg-rose-950/10 border border-rose-100/50 dark:border-rose-950/20 p-4 rounded-2xl flex items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs text-rose-600 dark:text-rose-400 font-bold uppercase tracking-wider">{t('tourOwner')}</div> <div className="text-xs text-rose-600 dark:text-rose-400 font-bold uppercase tracking-wider">{t('tourOwner')}</div>
<div className="text-sm font-black text-slate-900 dark:text-white truncate mt-0.5">{ownerContact.name || 'Anonymous'}</div> <div className="text-sm font-black text-[var(--text-primary)] dark:text-white truncate mt-0.5">{ownerContact.name || 'Anonymous'}</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{ownerContact.phone || t('noPhone')}</div> <div className="text-xs text-[var(--text-muted)] dark:text-slate-400 mt-0.5">{ownerContact.phone || t('noPhone')}</div>
</div> </div>
{ownerContact.phone && ( {ownerContact.phone && (
<a <a
@@ -374,11 +374,11 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
{/* Manager contacts */} {/* Manager contacts */}
{managerContacts.map((mgr, idx) => ( {managerContacts.map((mgr, idx) => (
<div key={idx} className="bg-slate-50 dark:bg-slate-950 border border-gray-100 dark:border-slate-800 p-4 rounded-2xl flex items-center justify-between gap-4"> <div key={idx} className="bg-[var(--background)] dark:bg-slate-950 border border-[var(--border)] dark:border-slate-800 p-4 rounded-2xl flex items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs text-slate-500 dark:text-slate-400 font-bold uppercase tracking-wider">{t('tourManager')}</div> <div className="text-xs text-[var(--text-muted)] dark:text-slate-400 font-bold uppercase tracking-wider">{t('tourManager')}</div>
<div className="text-sm font-black text-slate-900 dark:text-white truncate mt-0.5">{mgr.name}</div> <div className="text-sm font-black text-[var(--text-primary)] dark:text-white truncate mt-0.5">{mgr.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{mgr.phone}</div> <div className="text-xs text-[var(--text-muted)] dark:text-slate-400 mt-0.5">{mgr.phone}</div>
</div> </div>
<a <a
href={`tel:${mgr.phone}`} href={`tel:${mgr.phone}`}
@@ -390,20 +390,20 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
))} ))}
{!ownerContact?.phone && managerContacts.length === 0 && ( {!ownerContact?.phone && managerContacts.length === 0 && (
<p className="text-xs text-slate-400 dark:text-slate-500 italic">{t('noPhone')}</p> <p className="text-xs text-[var(--text-muted)] dark:text-slate-500 italic">{t('noPhone')}</p>
)} )}
</div> </div>
</div> </div>
{/* Stops Hierarchy List */} {/* Stops Hierarchy List */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest"> <h3 className="text-xs font-black text-[var(--text-muted)] dark:text-slate-500 uppercase tracking-widest">
{t('viewTimeline')} {t('viewTimeline')}
</h3> </h3>
<div className="space-y-4 relative before:absolute before:left-3 before:top-2 before:bottom-2 before:w-[2px] before:bg-gray-100 dark:before:bg-slate-800"> <div className="space-y-4 relative before:absolute before:left-3 before:top-2 before:bottom-2 before:w-[2px] before:bg-[var(--border)] dark:before:bg-slate-800">
{allLocations.length === 0 ? ( {allLocations.length === 0 ? (
<p className="text-xs text-slate-400 dark:text-slate-500 italic pl-6">{t('noStops')}</p> <p className="text-xs text-[var(--text-muted)] dark:text-slate-500 italic pl-6">{t('noStops')}</p>
) : ( ) : (
allLocations.map((loc, idx) => { allLocations.map((loc, idx) => {
const isStart = idx === 0; const isStart = idx === 0;
@@ -430,15 +430,15 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
</div> </div>
{/* Details */} {/* Details */}
<div className="flex-1 bg-slate-50/50 hover:bg-slate-50 dark:bg-slate-950/40 dark:hover:bg-slate-950/80 p-3 rounded-2xl border border-gray-100/50 dark:border-slate-800/40 transition-all select-none"> <div className="flex-1 bg-[var(--background)]/50 hover:bg-[var(--background)] dark:bg-slate-950/40 dark:hover:bg-slate-950/80 p-3 rounded-2xl border border-[var(--border)]/50 dark:border-slate-800/40 transition-all select-none">
<h4 className="text-xs font-black text-slate-800 dark:text-slate-200 group-hover:text-rose-500 dark:group-hover:text-rose-400 transition-colors"> <h4 className="text-xs font-black text-[var(--text-primary)] dark:text-slate-200 group-hover:text-rose-500 dark:group-hover:text-rose-400 transition-colors">
{loc.name} {loc.name}
</h4> </h4>
{loc.address && ( {loc.address && (
<p className="text-[10px] text-slate-500 dark:text-slate-400 truncate mt-0.5">{loc.address}</p> <p className="text-[10px] text-[var(--text-muted)] dark:text-slate-400 truncate mt-0.5">{loc.address}</p>
)} )}
{loc.plannedStart && ( {loc.plannedStart && (
<p className="text-[9px] text-slate-400 dark:text-slate-500 mt-1"> <p className="text-[9px] text-[var(--text-muted)] dark:text-slate-500 mt-1">
{new Date(loc.plannedStart).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(loc.plannedStart).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{loc.plannedEnd && ` - ${new Date(loc.plannedEnd).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`} {loc.plannedEnd && ` - ${new Date(loc.plannedEnd).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
</p> </p>
@@ -454,7 +454,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
</div> </div>
{/* Right Side Map Pane */} {/* Right Side Map Pane */}
<div className={`relative flex-1 lg:col-span-8 h-full bg-slate-100 dark:bg-slate-950 ${activeTab === 'map' ? 'flex' : 'hidden lg:flex'}`}> <div className={`relative flex-1 lg:col-span-8 h-full bg-[var(--background)] dark:bg-slate-950 ${activeTab === 'map' ? 'flex' : 'hidden lg:flex'}`}>
{allLocations.length > 0 ? ( {allLocations.length > 0 ? (
<MapContainer <MapContainer
center={[allLocations[0].latitude, allLocations[0].longitude]} center={[allLocations[0].latitude, allLocations[0].longitude]}
@@ -499,7 +499,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
<Popup> <Popup>
<div className="p-1"> <div className="p-1">
<div className="font-black text-xs text-slate-900">{loc.name}</div> <div className="font-black text-xs text-slate-900">{loc.name}</div>
{loc.address && <div className="text-[10px] text-gray-500 mt-0.5">{loc.address}</div>} {loc.address && <div className="text-[10px] text-[var(--text-muted)] mt-0.5">{loc.address}</div>}
</div> </div>
</Popup> </Popup>
</Marker> </Marker>
@@ -527,7 +527,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
{/* Locate Me button */} {/* Locate Me button */}
<button <button
onClick={handleLocateMe} onClick={handleLocateMe}
className="w-12 h-12 bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-slate-700 dark:text-slate-200 hover:bg-gray-50 dark:hover:bg-slate-800 transition-all active:scale-95" className="w-12 h-12 bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-800 transition-all active:scale-95"
title="Định vị của tôi" title="Định vị của tôi"
> >
{isLocating ? ( {isLocating ? (
@@ -541,7 +541,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
{allLocations.length > 0 && ( {allLocations.length > 0 && (
<button <button
onClick={() => setFitBoundsTrigger(prev => prev + 1)} onClick={() => setFitBoundsTrigger(prev => prev + 1)}
className="w-12 h-12 bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-slate-700 dark:text-slate-200 hover:bg-gray-50 dark:hover:bg-slate-800 transition-all active:scale-95" className="w-12 h-12 bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-800 transition-all active:scale-95"
title="Bao phủ toàn bộ" title="Bao phủ toàn bộ"
> >
<Compass className="w-5 h-5 text-violet-500" /> <Compass className="w-5 h-5 text-violet-500" />
+28 -28
View File
@@ -157,20 +157,20 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen flex items-center justify-center bg-[var(--background)] py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 bg-white p-10 rounded-3xl shadow-2xl"> <div className="max-w-md w-full space-y-8 bg-[var(--surface)] p-10 rounded-3xl shadow-2xl">
<button <button
onClick={onBack} onClick={onBack}
className="absolute top-6 left-6 text-gray-400 hover:text-gray-600 transition-colors" className="absolute top-6 left-6 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
> >
<ChevronLeft className="w-6 h-6" /> <ChevronLeft className="w-6 h-6" />
</button> </button>
<div className="mb-10"> <div className="mb-10">
<h1 className="text-3xl font-extrabold text-gray-900"> <h1 className="text-3xl font-extrabold text-[var(--text-primary)]">
{step === 'form' ? 'Tạo tài khoản mới' : 'Xác thực tài khoản'} {step === 'form' ? 'Tạo tài khoản mới' : 'Xác thực tài khoản'}
</h1> </h1>
<p className="text-gray-500 mt-2 font-medium"> <p className="text-[var(--text-muted)] mt-2 font-medium">
{step === 'form' {step === 'form'
? 'Khám phá các tính năng lập kế hoạch chuyên nghiệp.' ? 'Khám phá các tính năng lập kế hoạch chuyên nghiệp.'
: `Vui lòng nhập mã OTP đã được gửi tới ${formData.email}`} : `Vui lòng nhập mã OTP đã được gửi tới ${formData.email}`}
@@ -187,61 +187,61 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
{step === 'form' ? ( {step === 'form' ? (
<> <>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Họ tên</label> <label className="text-sm font-bold text-[var(--text-primary)] ml-1">Họ tên</label>
<div className="relative group"> <div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" /> <User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
<input <input
required required
type="text" type="text"
placeholder="Nhập họ và tên của bạn" placeholder="Nhập họ và tên của bạn"
value={formData.name} value={formData.name}
onChange={(e) => handleChange('name', e.target.value)} onChange={(e) => handleChange('name', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
/> />
</div> </div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Email</label> <label className="text-sm font-bold text-[var(--text-primary)] ml-1">Email</label>
<div className="relative group"> <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
required required
type="email" type="email"
placeholder="Nhập email của bạn" placeholder="Nhập email của bạn"
value={formData.email} value={formData.email}
onChange={(e) => handleChange('email', e.target.value)} onChange={(e) => handleChange('email', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label> <label className="text-sm font-bold text-[var(--text-primary)] ml-1">Mật khẩu</label>
<div className="relative group"> <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
required required
type="password" type="password"
placeholder="Nhập mật khẩu" placeholder="Nhập mật khẩu"
value={formData.password} value={formData.password}
onChange={(e) => handleChange('password', e.target.value)} onChange={(e) => handleChange('password', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
/> />
</div> </div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận mật khẩu</label> <label className="text-sm font-bold text-[var(--text-primary)] ml-1">Xác nhận mật khẩu</label>
<div className="relative group"> <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
required required
type="password" type="password"
placeholder="Xác nhận mật khẩu" placeholder="Xác nhận mật khẩu"
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)} onChange={(e) => handleChange('confirmPassword', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
/> />
</div> </div>
</div> </div>
@@ -249,28 +249,28 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label> <label className="text-sm font-bold text-[var(--text-primary)] ml-1">Số điện thoại</label>
<div className="relative group"> <div className="relative group">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" /> <Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
<input <input
type="tel" type="tel"
placeholder="Nhập số điện thoại" placeholder="Nhập số điện thoại"
value={formData.phone} value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)} onChange={(e) => handleChange('phone', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
/> />
</div> </div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Đa chỉ</label> <label className="text-sm font-bold text-[var(--text-primary)] ml-1">Đa chỉ</label>
<div className="relative group"> <div className="relative group">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" /> <MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
<input <input
type="text" type="text"
placeholder="Nhập địa chỉ" placeholder="Nhập địa chỉ"
value={formData.address} value={formData.address}
onChange={(e) => handleChange('address', e.target.value)} onChange={(e) => handleChange('address', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
/> />
</div> </div>
</div> </div>
@@ -278,9 +278,9 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
</> </>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-bold text-gray-700 ml-1"> xác thực OTP</label> <label className="text-sm font-bold text-[var(--text-primary)] ml-1"> xác thực OTP</label>
<div className="relative group"> <div className="relative group">
<ShieldCheck className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" /> <ShieldCheck className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
<input <input
required required
type="text" type="text"
@@ -288,7 +288,7 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
placeholder="Nhập 6 số mã OTP" placeholder="Nhập 6 số mã OTP"
value={otp} value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))} onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl text-center font-bold tracking-[0.5em] text-lg focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl text-center font-bold tracking-[0.5em] text-lg focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
/> />
</div> </div>
</div> </div>
@@ -308,9 +308,9 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
<> <>
<div className="relative my-6 flex items-center justify-center"> <div className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div> <div className="w-full border-t border-[var(--border)]"></div>
</div> </div>
<span className="relative px-3 bg-white text-xs font-bold text-gray-400 uppercase">Hoặc</span> <span className="relative px-3 bg-[var(--surface)] text-xs font-bold text-[var(--text-muted)] uppercase">Hoặc</span>
</div> </div>
<div id="google-signin-btn-signup" className="w-full flex justify-center"></div> <div id="google-signin-btn-signup" className="w-full flex justify-center"></div>
+17 -16
View File
@@ -310,23 +310,23 @@ const MapContextMenu = ({ onAction, onOpen }: { onAction: (action: string, latln
return ( return (
<div <div
ref={menuRef} ref={menuRef}
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200" className="absolute z-[2000] bg-[var(--surface)] rounded-2xl shadow-2xl border border-[var(--border)] py-2 w-48 animate-in zoom-in-95 duration-200"
style={{ top: menuPos.y, left: menuPos.x }} style={{ top: menuPos.y, left: menuPos.x }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
<button onClick={() => { onAction('START', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2"> <button onClick={() => { onAction('START', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-[var(--background)] text-sm font-bold text-[var(--text-secondary)] flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-600" /> Bắt đu từ đây <div className="w-2 h-2 rounded-full bg-blue-600" /> Bắt đu từ đây
</button> </button>
<button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-green-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50"> <button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-[var(--background)] text-sm font-bold text-[var(--text-secondary)] flex items-center gap-2 border-b border-[var(--border)]">
<div className="w-2 h-2 rounded-full bg-green-600" /> Kết thúc đây <div className="w-2 h-2 rounded-full bg-green-600" /> Kết thúc đây
</button> </button>
<div className="px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest">Thêm vào chặng</div> <div className="px-4 py-2 text-[10px] font-black text-[var(--text-muted)] uppercase tracking-widest">Thêm vào chặng</div>
{legs.map(leg => ( {legs.map(leg => (
<button <button
key={leg.id} key={leg.id}
onClick={() => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }} onClick={() => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-medium text-gray-600 truncate" className="w-full text-left px-4 py-2 hover:bg-[var(--background)] text-sm font-medium text-[var(--text-secondary)] truncate"
> >
Chặng {leg.sequence}: {leg.note || 'Không có ghi chú'} Chặng {leg.sequence}: {leg.note || 'Không có ghi chú'}
</button> </button>
@@ -1706,13 +1706,13 @@ export const TourDetailPage = ({
}; };
return ( return (
<div className="min-h-screen bg-gray-50 pb-20"> <div className="min-h-screen bg-[var(--background)] pb-20">
{/* Top Navigation Bar */} {/* Top Navigation Bar */}
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between"> <div className="sticky top-0 z-30 bg-[var(--surface)]/80 backdrop-blur-md border-b border-[var(--border)] px-4 py-3 flex items-center justify-between">
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors"> <button onClick={onBack} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" /> <ChevronLeft className="w-6 h-6 text-[var(--text-secondary)]" />
</button> </button>
<h1 className="text-lg font-bold text-gray-800 truncate px-4 flex-1 text-center"> <h1 className="text-lg font-bold text-[var(--text-primary)] truncate px-4 flex-1 text-center">
{tourInfo.title} {tourInfo.title}
</h1> </h1>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -2035,8 +2035,7 @@ export const TourDetailPage = ({
{activeTab === 'plan' && ( {activeTab === 'plan' && (
<div className="animate-in fade-in slide-in-from-bottom-2"> <div className="animate-in fade-in slide-in-from-bottom-2">
{/* View Mode Toggle */} {/* View Mode Toggle */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-center items-center mb-3">
<div className="flex-1" />
<div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1"> <div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1">
<button <button
onClick={() => setViewMode('timeline')} onClick={() => setViewMode('timeline')}
@@ -2051,21 +2050,23 @@ export const TourDetailPage = ({
<MapIconLucide className="w-3.5 h-3.5" /> Bản đ <MapIconLucide className="w-3.5 h-3.5" /> Bản đ
</button> </button>
</div> </div>
<div className="flex-1 flex justify-end gap-2"> </div>
{/* Export Buttons */}
<div className="flex justify-center gap-2 mb-6">
<button <button
onClick={handleExportCSV} onClick={handleExportCSV}
className="flex items-center gap-1.5 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95 whitespace-nowrap" className="flex items-center gap-1 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-md transition-all active:scale-95 whitespace-nowrap"
> >
📊 Google Sheets 📊 Google Sheets
</button> </button>
<button <button
onClick={handleExportPDF} onClick={handleExportPDF}
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95 whitespace-nowrap" className="flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold shadow-md transition-all active:scale-95 whitespace-nowrap"
> >
📥 {t('exportPDF') || 'Xuất PDF'} 📥 {t('exportPDF') || 'Xuất PDF'}
</button> </button>
</div> </div>
</div>
{viewMode === 'timeline' ? ( {viewMode === 'timeline' ? (
<ItineraryTimeline onAddLocation={(legId, isStart, isEnd) => { <ItineraryTimeline onAddLocation={(legId, isStart, isEnd) => {
+52 -1
View File
@@ -7,7 +7,58 @@ const config: Config = {
'./src/**/*.{js,ts,jsx,tsx}', './src/**/*.{js,ts,jsx,tsx}',
], ],
theme: { theme: {
extend: {}, extend: {
colors: {
// Light Theme Colors ("Classic Heritage & Soft Sand")
yotripLight: {
bg: '#f6f0ed', // Parchment - Global Page Backgrounds
text: '#28536b', // Yale Blue - Primary Text & Main Titles
steel: '#7ea8be', // Steel Blue - Active Navigation & Secondary Actions
khaki: '#bbb193', // Khaki Beige - Borders, Dividers, Muted States
rosy: '#c2948a', // Rosy Taupe - Special Highlight Badges, Notifications
},
// Dark Theme Colors ("Vintage Velvet & Aquatic Zest")
yotripDark: {
bg: '#513b56', // Vintage Grape - Global Page Backgrounds
surface: '#525174', // Dusty Grape - Component Surfaces (Cards, Bubbles)
lime: '#bce784', // Lime Cream - Brand Text Highlights, Active Icons
bondi: '#348aa7', // Bondi Blue - Primary Action Buttons, Links
emerald: '#5dd39e', // Emerald - Success Utilities & "Tối ưu" badges
},
// CSS Variable based colors for smooth theme support
theme: {
bg: 'var(--background)',
'bg-alt': 'var(--background-alt)',
surface: 'var(--surface)',
'surface-muted': 'var(--surface-muted)',
'surface-hover': 'var(--surface-hover)',
border: 'var(--border)',
'border-light': 'var(--border-light)',
'text': 'var(--text-primary)',
'text-secondary': 'var(--text-secondary)',
'text-accent': 'var(--text-accent)',
'text-muted': 'var(--text-muted)',
'text-disabled': 'var(--text-disabled)',
primary: 'var(--primary)',
'primary-hover': 'var(--primary-hover)',
'primary-light': 'var(--primary-light)',
secondary: 'var(--secondary)',
'secondary-light': 'var(--secondary-light)',
success: 'var(--success)',
'success-light': 'var(--success-light)',
danger: 'var(--danger)',
'danger-light': 'var(--danger-light)',
warning: 'var(--warning)',
'warning-light': 'var(--warning-light)',
info: 'var(--info)',
'info-light': 'var(--info-light)',
}
},
boxShadow: {
'theme-sm': 'var(--shadow-sm)',
'theme-md': 'var(--shadow-md)',
}
},
}, },
plugins: [], plugins: [],
}; };