fix: lỗi hiển thị trong itineraryTimeline
This commit is contained in:
@@ -0,0 +1,102 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# To AI Agent: Refactor Itinerary Timeline into an Exclusive File Tree Directory System (Single Expansion Mode)
|
||||||
|
|
||||||
|
## 1. Context & Architectural Analogy
|
||||||
|
We are refactoring the `ItineraryTimeline` component on the mobile view (`yotrip.labz.io.vn`) using a **File Tree Directory System** analogy:
|
||||||
|
- **Parent Folders ("Thư mục mẹ"):** Represented by the Stages ("Chặng 1", "Chặng 2"...).
|
||||||
|
- **Child Nodes ("Thư mục con"):** Represented by the Locations/Destinations inside that stage (`Trần Cao Vân`, `Đồng Khởi`...).
|
||||||
|
|
||||||
|
### CRITICAL LOGIC CONSTRAINT (Exclusive Accordion):
|
||||||
|
- When a Parent Folder (Stage) is **collapsed**, all of its Child Nodes (Locations) must immediately hide cleanly inside it.
|
||||||
|
- **Single Expansion Rule:** The system must enforce an **exclusive single-expansion mode**. On the entire timeline screen, **MAXIMUM ONE** Parent Folder can be expanded at any given time.
|
||||||
|
- Opening/Expanding a new Stage folder must **automatically collapse** whichever Stage folder was previously open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. State Management Specification (For Script/Logic Implementation)
|
||||||
|
|
||||||
|
To enforce the "Maximum 1 Expanded Folder" rule, do NOT use isolated individual boolean flags for each stage. Instead, implement a centralized single-active-state variable:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Example React State Hook Blueprint:
|
||||||
|
// Track the ID or index of the single active expanded stage folder.
|
||||||
|
// If null, all stages are collapsed.
|
||||||
|
const [expandedStageId, setExpandedStageId] = useState(initialStageId);
|
||||||
|
|
||||||
|
const handleStageToggle = (stageId) => {
|
||||||
|
// If clicking the already open folder, close it. Otherwise, open the new one and shut the rest.
|
||||||
|
setExpandedStageId(prevId => prevId === stageId ? null : stageId);
|
||||||
|
};
|
||||||
|
|
||||||
|
## 3. Component DOM Tree & CSS Blueprint
|
||||||
|
### A. Component Layout Structure
|
||||||
|
|
||||||
|
<div class="file-tree-itinerary-container">
|
||||||
|
|
||||||
|
<div class="fixed-top-header-block">
|
||||||
|
<header class="main-tour-header">...</header>
|
||||||
|
<nav class="sub-navigation-tabs">...</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="directory-scroll-viewport">
|
||||||
|
|
||||||
|
<section class="folder-node-wrapper collapsed">
|
||||||
|
<div class="folder-header-row" onclick="handleStageToggle('stage_1')">
|
||||||
|
<span class="folder-badge-index">1</span>
|
||||||
|
<h3 class="folder-title">Chặng khởi đầu</h3>
|
||||||
|
</div>
|
||||||
|
<div class="folder-child-content-box">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="folder-node-wrapper expanded">
|
||||||
|
<div class="folder-header-row" onclick="handleStageToggle('stage_2')">
|
||||||
|
<span class="folder-badge-index">2</span>
|
||||||
|
<h3 class="folder-title">Chặng 2</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="folder-child-content-box">
|
||||||
|
<div class="child-nodes-list">
|
||||||
|
<div class="location-child-card">Trần Cao Vân</div>
|
||||||
|
<div class="location-child-card">Đồng Khởi</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### B. Core CSS Layout Styles
|
||||||
|
|
||||||
|
.file-tree-itinerary-container {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
height: 100dvh !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-scroll-viewport {
|
||||||
|
flex: 1 1 0% !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base style for folder blocks stacked flush against each other */
|
||||||
|
.folder-node-wrapper {
|
||||||
|
width: 100% !important;
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin-bottom: 1px !important; /* Micro hair-line divider between folders */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-header-row {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
/* Sticky behavior remains active when scrolling within an open directory */
|
||||||
|
position: sticky !important;
|
||||||
|
top: 0px;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS Grid Transition Engine for smooth directory expansions */
|
||||||
|
.folder-child-content-box {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-nodes-list {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- MUTUALLY EXCLUSIVE STATE STYLES --- */
|
||||||
|
|
||||||
|
/* Collapsed Folder: Sub-files shrink immediately to 0 height */
|
||||||
|
.folder-node-wrapper.collapsed .folder-child-content-box {
|
||||||
|
grid-template-rows: 0fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded Folder: Opens dynamically to accommodate dynamic content height */
|
||||||
|
.folder-node-wrapper.expanded .folder-child-content-box {
|
||||||
|
grid-template-rows: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
## 4. Acceptance Criteria for AI Agent Verification
|
||||||
|
[ ] Mutually Exclusive Test: Clicking an inactive Stage header while another stage is open must trigger a simultaneous transition: the old stage collapses back into a tight header row, and the clicked stage expands its location cards.
|
||||||
|
|
||||||
|
[ ] Zero-Gap Validation: All collapsed folder components must sit completely flush against the top tab block and one another, eliminating all gray margin bleeding.
|
||||||
|
|
||||||
|
[ ] Under-Clip Containment: When scrolling a long open folder, the list items must slide behind their sticky active parent folder row and disappear without visual overlapping artifacts.
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# To AI Agent: Implement Mobile Horizontal Image Panning Component
|
|
||||||
|
|
||||||
## 1. Context & Objective
|
|
||||||
We are developing a travel application. We need to implement a mobile-first image viewer component.
|
|
||||||
**CRITICAL REQUIREMENT:** When a user swipes/drags horizontally on mobile, the UI must NOT switch to the next image. Instead, it must smoothly scroll/pan horizontally to reveal the hidden, unexposed parts of the *same* wide/panoramic image.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Technical Stack & Scope
|
|
||||||
- **Target Platform:** Mobile Web / Responsive (Touch-friendly).
|
|
||||||
- **Preferred Method:** CSS-First approach utilizing Viewport Overflow (for optimal GPU performance and native inertia scrolling).
|
|
||||||
- **Avoid:** Do NOT use global slider libraries (like standard Swiper/Slick) if they force image switching behavior.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. UI/UX Specifications
|
|
||||||
|
|
||||||
### A. DOM Structure
|
|
||||||
- A wrapper/container acting as the "window view" (`.image-pan-container`).
|
|
||||||
- The target wide/panoramic image (`.image-pan-element`).
|
|
||||||
|
|
||||||
### B. CSS Rules & Constraints
|
|
||||||
1. **Container (`.image-pan-container`):**
|
|
||||||
- Must have a fixed width (e.g., `100vw` or `100%` of parent).
|
|
||||||
- Must set `overflow-x: auto` and `overflow-y: hidden` to enable horizontal touch scrolling only.
|
|
||||||
- Must enable smooth scrolling (`scroll-behavior: smooth`) and native touch momentum (`-webkit-overflow-scrolling: touch`).
|
|
||||||
- **Crucial:** Hide the native scrollbar across all major browsers (Webkit, Firefox, IE/Edge) to make it look like a native mobile app feature.
|
|
||||||
|
|
||||||
2. **Image Element (`.image-pan-element`):**
|
|
||||||
- Must fit the container's height perfectly (`height: 100%`).
|
|
||||||
- Width must be calculated automatically based on aspect ratio (`width: auto`).
|
|
||||||
- Must override any global framework styles: enforce `max-width: none !important`.
|
|
||||||
- Do NOT use `object-fit: cover` as it will crop the scrolling data.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Reference Code Blueprint
|
|
||||||
|
|
||||||
Use the following snippet as a baseline for your implementation:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="image-pan-container">
|
|
||||||
<img src="YOUR_PANORAMIC_IMAGE_URL" class="image-pan-element" alt="Panoramic View" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
/* Styling Architecture */
|
|
||||||
.image-pan-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 400px; /* Adjust height based on project guidelines */
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbars entirely */
|
|
||||||
.image-pan-container::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.image-pan-container {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-pan-element {
|
|
||||||
height: 100%;
|
|
||||||
width: auto;
|
|
||||||
max-width: none !important;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
5. Acceptance Criteria
|
|
||||||
[ ] The wide photo fills the component height and overflows horizontally without distortion.
|
|
||||||
|
|
||||||
[ ] Users can smoothly swipe left/right with their fingers to view all details of the photo.
|
|
||||||
|
|
||||||
[ ] No desktop/mobile scrollbars are visible during the interaction.
|
|
||||||
|
|
||||||
[ ] Ensure max-width override is active so Tailwind or other CSS frameworks don't crush the image width to 100%.
|
|
||||||
Vendored
+3
@@ -1582,6 +1582,7 @@ let TourController = class TourController {
|
|||||||
async joinByToken(body, req) {
|
async joinByToken(body, req) {
|
||||||
const { token } = body;
|
const { token } = body;
|
||||||
console.log('[joinByToken] Request received, token length:', token ? token.length : 0);
|
console.log('[joinByToken] Request received, token length:', token ? token.length : 0);
|
||||||
|
console.log('[joinByToken] Token received (full):', token);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.error('[joinByToken] No token provided');
|
console.error('[joinByToken] No token provided');
|
||||||
throw new common_1.BadRequestException('Vui lòng cung cấp token lời mời.');
|
throw new common_1.BadRequestException('Vui lòng cung cấp token lời mời.');
|
||||||
@@ -1595,6 +1596,8 @@ let TourController = class TourController {
|
|||||||
console.error('[joinByToken] Invitation not found for token:', token.substring(0, 20) + '...');
|
console.error('[joinByToken] Invitation not found for token:', token.substring(0, 20) + '...');
|
||||||
const totalInvitations = await this.prisma.tourInvitation.count();
|
const totalInvitations = await this.prisma.tourInvitation.count();
|
||||||
console.log('[joinByToken] Total invitations in database:', totalInvitations);
|
console.log('[joinByToken] Total invitations in database:', totalInvitations);
|
||||||
|
const allInvitations = await this.prisma.tourInvitation.findMany({ select: { token: true, email: true, tourId: true } });
|
||||||
|
console.log('[joinByToken] All invitation tokens:', allInvitations);
|
||||||
throw new common_1.NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.');
|
throw new common_1.NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.');
|
||||||
}
|
}
|
||||||
const userEmail = req.user.email;
|
const userEmail = req.user.email;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 868 KiB |
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+5
-5
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
<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>
|
||||||
<script type="module" crossorigin src="/assets/index-Chfn55jq.js"></script>
|
<script type="module" crossorigin src="/assets/index-FtyTkPzF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D_50XTpY.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DQ8dDKht.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ export const ItineraryTimeline = ({
|
|||||||
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
||||||
const [commentLocationId, setCommentLocationId] = useState('');
|
const [commentLocationId, setCommentLocationId] = useState('');
|
||||||
const [commentLocationName, setCommentLocationName] = useState('');
|
const [commentLocationName, setCommentLocationName] = useState('');
|
||||||
|
|
||||||
|
// State to track single expanded stage (exclusive single-expansion mode)
|
||||||
|
const [expandedStageId, setExpandedStageId] = useState<string | null>(legs.length > 0 ? legs[0]?.id : null);
|
||||||
|
|
||||||
|
const toggleStageExpanded = (legId: string) => {
|
||||||
|
// Exclusive mode: if clicking the same stage, close it. Otherwise, open only the clicked one.
|
||||||
|
setExpandedStageId(prevId => prevId === legId ? null : legId);
|
||||||
|
};
|
||||||
|
|
||||||
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
|
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
|
||||||
const handleCommentIncrement = (locationId: string) => {
|
const handleCommentIncrement = (locationId: string) => {
|
||||||
@@ -197,11 +205,14 @@ export const ItineraryTimeline = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("ItineraryTimeline: Legs updated", legs);
|
console.log("ItineraryTimeline: Legs updated", legs);
|
||||||
|
// Initialize first leg as expanded when legs change
|
||||||
|
if (legs.length > 0 && !expandedStageId) {
|
||||||
|
setExpandedStageId(legs[0].id);
|
||||||
|
}
|
||||||
}, [legs]);
|
}, [legs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="itinerary-timeline-print-zone" className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
<div id="itinerary-timeline-print-zone" className="timeline-scroll-container itinerary-timeline-container">
|
||||||
<div className="px-2 pt-4">
|
|
||||||
{legs.length === 0 ? (
|
{legs.length === 0 ? (
|
||||||
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
||||||
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||||
@@ -220,9 +231,12 @@ export const ItineraryTimeline = ({
|
|||||||
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
<section key={leg.id} className={`folder-node-wrapper ${expandedStageId === leg.id ? 'expanded' : 'collapsed'}`}>
|
||||||
{/* Leg Header */}
|
{/* Folder header row - clickable to toggle exclusive expansion */}
|
||||||
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
|
<div
|
||||||
|
onClick={() => toggleStageExpanded(leg.id)}
|
||||||
|
className="folder-header-row animate-in fade-in slide-in-from-bottom-4 duration-300 hover:bg-gray-50/50 transition-colors"
|
||||||
|
>
|
||||||
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
|
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm shrink-0">
|
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm shrink-0">
|
||||||
@@ -245,24 +259,24 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4" onClick={(e) => e.stopPropagation()}>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0)}
|
onClick={(e) => { e.stopPropagation(); onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0); }}
|
||||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
||||||
title="Thêm địa điểm vào chặng này"
|
title="Thêm địa điểm vào chặng này"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditLeg(leg)}
|
onClick={(e) => { e.stopPropagation(); handleEditLeg(leg); }}
|
||||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteLeg(leg.id)}
|
onClick={(e) => { e.stopPropagation(); handleDeleteLeg(leg.id); }}
|
||||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@@ -298,12 +312,16 @@ export const ItineraryTimeline = ({
|
|||||||
)} {/* Only show optimize button if canEdit */}
|
)} {/* Only show optimize button if canEdit */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vertical Line for the whole leg */}
|
{/* Scrollable content body with proper z-index layering */}
|
||||||
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
|
<div className="child-nodes-list-wrapper">
|
||||||
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
|
{/* Folder child content box - Grid accordion for exclusive expansion */}
|
||||||
|
<div className={`folder-child-content-box ${expandedStageId === leg.id ? 'expanded' : ''}`}>
|
||||||
|
<div className="child-nodes-list relative">
|
||||||
|
{/* Vertical Line for the whole leg - Dynamic height */}
|
||||||
|
<div className="absolute left-6 top-16 w-0.5 bg-blue-100 -z-0 h-full" />
|
||||||
|
|
||||||
<div className="ml-2">
|
<div className="ml-2">
|
||||||
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
|
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
|
||||||
{legIdx === 0 && !leg.locations.some((loc: any) => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
|
{legIdx === 0 && !leg.locations.some((loc: any) => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
|
||||||
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
|
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
|
||||||
<div className="z-10 mt-1.5 mr-4">
|
<div className="z-10 mt-1.5 mr-4">
|
||||||
@@ -522,8 +540,11 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div> {/* End of ml-2 wrapper */}
|
||||||
</div>
|
</div> {/* End of child-nodes-list */}
|
||||||
|
</div> {/* End of folder-child-content-box */}
|
||||||
|
</div> {/* End of child-nodes-list-wrapper */}
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
@@ -546,7 +567,6 @@ export const ItineraryTimeline = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal Khai báo số chặng (Popover) */}
|
{/* Modal Khai báo số chặng (Popover) */}
|
||||||
{isLegCountModalOpen && (
|
{isLegCountModalOpen && (
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { useNotification } from '@/hooks/useNotification';
|
|||||||
|
|
||||||
interface TourChatProps {
|
interface TourChatProps {
|
||||||
tourId: string;
|
tourId: string;
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false }) => {
|
||||||
const notify = useNotification();
|
const notify = useNotification();
|
||||||
const [messages, setMessages] = useState<any[]>([]);
|
const [messages, setMessages] = useState<any[]>([]);
|
||||||
const [newMessage, setNewMessage] = useState('');
|
const [newMessage, setNewMessage] = useState('');
|
||||||
@@ -416,21 +417,24 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// currentUserId is defined at the top
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-150 rounded-2xl shadow-lg overflow-hidden flex flex-col h-[500px]">
|
<div className={`chat-viewport-wrapper flex flex-col !overflow-hidden !w-full ${embedded ? 'h-full flex-1' : 'h-[100dvh]'} bg-white`}>
|
||||||
{/* Chat Header */}
|
{/* Fixed Top Layout Block - Header and Tabs Container */}
|
||||||
<div className="p-4 border-b border-gray-150 flex items-center gap-2 bg-gray-50/50">
|
<div className="fixed-top-layout-block flex-shrink-0 !w-full z-50 bg-white border-b border-gray-200">
|
||||||
<MessageSquare className="w-5 h-5 text-blue-500" />
|
{/* Chat Header - Only show when not embedded */}
|
||||||
<div>
|
{!embedded && (
|
||||||
<h3 className="text-sm font-bold text-gray-800">Trò chuyện nhóm hành trình</h3>
|
<div className="p-4 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||||
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đổi thông tin, hình ảnh và định vị giữa các thành viên</p>
|
<MessageSquare className="w-5 h-5 text-blue-500" />
|
||||||
</div>
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-gray-800">Trò chuyện nhóm hành trình</h3>
|
||||||
|
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đổi thông tin, hình ảnh và định vị giữa các thành viên</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages list */}
|
{/* Chat History Scroll Viewport - ONLY scrollable area */}
|
||||||
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-3 min-h-0 bg-slate-50/20">
|
<div className="chat-history-scroll-viewport flex-1 min-h-0 !overflow-y-auto !overflow-x-hidden p-4 flex flex-col gap-3 bg-slate-50/20">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex-1 flex items-center justify-center text-gray-400 text-xs gap-1.5">
|
<div className="flex-1 flex items-center justify-center text-gray-400 text-xs gap-1.5">
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" /> Đang tải tin nhắn...
|
<Loader2 className="w-4 h-4 animate-spin text-blue-500" /> Đang tải tin nhắn...
|
||||||
@@ -468,7 +472,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
|||||||
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${
|
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${
|
||||||
isMe
|
isMe
|
||||||
? 'bg-blue-600 text-white rounded-tr-none'
|
? 'bg-blue-600 text-white rounded-tr-none'
|
||||||
: 'bg-white text-gray-700 rounded-tl-none border border-gray-150 shadow-sm'
|
: 'bg-white text-gray-700 rounded-tl-none border border-gray-200 shadow-sm'
|
||||||
}`}>
|
}`}>
|
||||||
{/* Attachment Image */}
|
{/* Attachment Image */}
|
||||||
{msg.attachmentUrl && (
|
{msg.attachmentUrl && (
|
||||||
@@ -498,7 +502,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
|||||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${
|
||||||
isMe
|
isMe
|
||||||
? 'bg-blue-700 border-blue-600 text-blue-100 hover:bg-blue-800'
|
? 'bg-blue-700 border-blue-600 text-blue-100 hover:bg-blue-800'
|
||||||
: 'bg-gray-100 border-gray-200 text-gray-750 hover:bg-gray-200'
|
: 'bg-gray-100 border-gray-200 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
|
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
|
||||||
@@ -525,7 +529,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
|||||||
|
|
||||||
{/* Previews (Image & GPS Location) */}
|
{/* Previews (Image & GPS Location) */}
|
||||||
{(imagePreview || attachedLocation) && (
|
{(imagePreview || attachedLocation) && (
|
||||||
<div className="px-4 py-2 border-t border-gray-150 bg-gray-50/80 flex flex-wrap gap-2">
|
<div className="px-4 py-2 border-t border-gray-200 bg-gray-50/80 flex flex-wrap gap-2 !flex-shrink-0">
|
||||||
{imagePreview && (
|
{imagePreview && (
|
||||||
<div className="relative w-16 h-16 rounded-lg overflow-hidden border border-gray-200 shadow-sm">
|
<div className="relative w-16 h-16 rounded-lg overflow-hidden border border-gray-200 shadow-sm">
|
||||||
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||||
@@ -557,8 +561,8 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat Input wrapper */}
|
{/* Locked Chat Input Footer */}
|
||||||
<div className="relative">
|
<div className="locked-chat-input-footer !flex-shrink-0 !w-full !border-t border-gray-200 bg-white !z-40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 12px)' }}>
|
||||||
{/* Mention list dropdown */}
|
{/* Mention list dropdown */}
|
||||||
{showMentionList && filteredParticipants.length > 0 && (
|
{showMentionList && filteredParticipants.length > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -589,7 +593,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
|||||||
{/* Chat Input form */}
|
{/* Chat Input form */}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSendMessage}
|
onSubmit={handleSendMessage}
|
||||||
className="p-3 border-t border-gray-150 bg-gray-50 flex gap-2 items-center"
|
className="p-3 flex gap-2 items-center !w-full relative"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -1,10 +1,299 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--main-header-height: 56px;
|
||||||
|
--sub-nav-height: 48px;
|
||||||
|
--combined-top-height: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
html, body, #root, .app-container {
|
html, body, #root, .app-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix viewport overflow on all container levels */
|
||||||
|
body, html {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itinerary-timeline-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-top-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: var(--main-header-height);
|
||||||
|
z-index: 50;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-nav-menu {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--main-header-height);
|
||||||
|
height: var(--sub-nav-height);
|
||||||
|
z-index: 40;
|
||||||
|
width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Master viewport wrapper - contains entire itinerary */
|
||||||
|
.itinerary-viewport {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
height: 100dvh !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itinerary-viewport-wrapper {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
height: 100dvh !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global sticky header zone - locks main header + sub-nav at top */
|
||||||
|
.global-sticky-header-zone {
|
||||||
|
position: sticky !important;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100 !important;
|
||||||
|
background-color: #ffffff;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed header block (handled at parent TourDetailPage level) */
|
||||||
|
.fixed-top-header-block {
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline scroll container - main scrollable region */
|
||||||
|
.directory-scroll-viewport {
|
||||||
|
flex: 1 !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 10 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-scroll-container {
|
||||||
|
flex: 1 !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 10 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable timeline area - removes all padding gaps */
|
||||||
|
.timeline-scroll-area {
|
||||||
|
flex: 1 1 0% !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin-top: 0px !important;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-scroll-viewport {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main timeline container with dynamic height support */
|
||||||
|
.itinerary-timeline-container {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
height: auto !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual folder node wrapper - base stage container */
|
||||||
|
.folder-node-wrapper {
|
||||||
|
width: 100% !important;
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin-bottom: 1px !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual stage card block - backwards compatibility */
|
||||||
|
.stage-card-block {
|
||||||
|
width: 100% !important;
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin-bottom: 1px !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage section - semantic wrapper */
|
||||||
|
.stage-section {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: block !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Folder header row - sticky positioning for pinned effect */
|
||||||
|
.folder-header-row {
|
||||||
|
position: sticky !important;
|
||||||
|
top: 0px !important;
|
||||||
|
z-index: 30 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-header-row:hover {
|
||||||
|
background-color: #f9fafb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage header row - sticky positioning for pinned effect */
|
||||||
|
.stage-header-row {
|
||||||
|
position: sticky !important;
|
||||||
|
top: 0px !important;
|
||||||
|
z-index: 40 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-header-row:hover {
|
||||||
|
background-color: #f9fafb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternate sticky header styling (kept for backwards compatibility) */
|
||||||
|
.stage-sticky-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background-color: #ffffff;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Folder child content box - Grid accordion for exclusive expansion */
|
||||||
|
.folder-child-content-box {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded folder state - children visible */
|
||||||
|
.folder-child-content-box.expanded {
|
||||||
|
grid-template-rows: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrapper for child nodes list - proper z-index layering */
|
||||||
|
.child-nodes-list-wrapper {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20 !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Child nodes list - content container within expanded folder */
|
||||||
|
.child-nodes-list {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS Grid Accordion Engine - Smooth expand/collapse without layout shattering */
|
||||||
|
.stage-accordion-content {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.25s ease-out !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded state - opens to full content height */
|
||||||
|
.stage-accordion-content.expanded {
|
||||||
|
grid-template-rows: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable content body - lower z-index so it scrolls behind header */
|
||||||
|
.stage-scrollable-content-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20 !important;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inner content wrapper - structural compliance */
|
||||||
|
.stage-points-list {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0px;
|
||||||
|
padding: 0 16px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy accordion container classes (for backwards compatibility) */
|
||||||
|
.stage-accordion-transition-container {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-accordion-transition-container.expanded {
|
||||||
|
grid-template-rows: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-accordion-inner-content {
|
||||||
|
overflow: hidden !important;
|
||||||
|
min-height: 0px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-content-body {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1681,9 +1681,9 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 4: REALTIME CHAT */}
|
{/* TAB 4: REALTIME CHAT */}
|
||||||
{activeTab === 'chats' && (
|
{activeTab === 'chats' && (
|
||||||
<div className="flex-1 w-full bg-slate-900/40 border-0 md:border border-slate-800/80 md:rounded-3xl overflow-hidden flex animate-in fade-in slide-in-from-bottom-2 duration-300">
|
<div className="flex flex-col h-[100dvh] md:h-auto w-full bg-slate-900/40 border-0 md:border border-slate-800/80 md:rounded-3xl overflow-hidden flex animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
|
||||||
{/* Chats List sidebar: show if not mobile OR if mobile and no active chat user */}
|
{/* Chats List sidebar: show if not mobile OR if mobile and no active chat user */}
|
||||||
{(!isMobile || !activeChatUser) && (
|
{(!isMobile || !activeChatUser) && (
|
||||||
|
|||||||
@@ -2003,9 +2003,9 @@ export const TourDetailPage = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
|
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
|
||||||
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`}>
|
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} px-4 pb-24`}>
|
||||||
{/* Tab Switcher */}
|
{/* Tab Switcher */}
|
||||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20">
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-[60px] z-40">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -2013,7 +2013,7 @@ export const TourDetailPage = ({
|
|||||||
setActiveTab(tab.id as any);
|
setActiveTab(tab.id as any);
|
||||||
if (tab.id === 'chat') setUnreadChatCount(0);
|
if (tab.id === 'chat') setUnreadChatCount(0);
|
||||||
}}
|
}}
|
||||||
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all relative ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
|
className={`flex-1 flex items-center justify-center py-2 rounded-xl text-xs font-semibold transition-all relative ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||||
@@ -3004,8 +3004,17 @@ export const TourDetailPage = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'chat' && currentTour && (
|
{activeTab === 'chat' && currentTour && (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2">
|
<div className="fixed left-0 right-0 bottom-0 flex flex-col bg-white z-[15]" style={{top: 'calc(60px + 5px)'}}>
|
||||||
<TourChat tourId={currentTour.id} />
|
{/* Chat Group Header Banner - Fixed below nav + tabs, sticky to title */}
|
||||||
|
<div className="flex-shrink-0 p-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-5 h-5 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-gray-800">Trò chuyện trong nhóm</h3>
|
||||||
|
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đổi thông tin, hình ảnh và định vị giữa các thành viên</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* TourChat with proper scrollable area */}
|
||||||
|
<TourChat tourId={currentTour.id} embedded={true} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user