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) {
|
||||
const { token } = body;
|
||||
console.log('[joinByToken] Request received, token length:', token ? token.length : 0);
|
||||
console.log('[joinByToken] Token received (full):', token);
|
||||
if (!token) {
|
||||
console.error('[joinByToken] No token provided');
|
||||
throw new common_1.BadRequestException('Vui lòng cung cấp token lời mời.');
|
||||
@@ -1595,6 +1596,8 @@ let TourController = class TourController {
|
||||
console.error('[joinByToken] Invitation not found for token:', token.substring(0, 20) + '...');
|
||||
const totalInvitations = await this.prisma.tourInvitation.count();
|
||||
console.log('[joinByToken] Total invitations in database:', totalInvitations);
|
||||
const allInvitations = await this.prisma.tourInvitation.findMany({ select: { token: true, email: true, tourId: true } });
|
||||
console.log('[joinByToken] All invitation tokens:', allInvitations);
|
||||
throw new common_1.NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.');
|
||||
}
|
||||
const userEmail = req.user.email;
|
||||
|
||||
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" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
<script type="module" crossorigin src="/assets/index-Chfn55jq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D_50XTpY.css">
|
||||
<script type="module" crossorigin src="/assets/index-FtyTkPzF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DQ8dDKht.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -76,6 +76,14 @@ export const ItineraryTimeline = ({
|
||||
const [commentLocationId, setCommentLocationId] = useState('');
|
||||
const [commentLocationName, setCommentLocationName] = useState('');
|
||||
|
||||
// State to track single expanded stage (exclusive single-expansion mode)
|
||||
const [expandedStageId, setExpandedStageId] = useState<string | null>(legs.length > 0 ? legs[0]?.id : null);
|
||||
|
||||
const toggleStageExpanded = (legId: string) => {
|
||||
// Exclusive mode: if clicking the same stage, close it. Otherwise, open only the clicked one.
|
||||
setExpandedStageId(prevId => prevId === legId ? null : legId);
|
||||
};
|
||||
|
||||
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
|
||||
const handleCommentIncrement = (locationId: string) => {
|
||||
const currentLegs = useTourStore.getState().legs;
|
||||
@@ -197,11 +205,14 @@ export const ItineraryTimeline = ({
|
||||
|
||||
useEffect(() => {
|
||||
console.log("ItineraryTimeline: Legs updated", legs);
|
||||
// Initialize first leg as expanded when legs change
|
||||
if (legs.length > 0 && !expandedStageId) {
|
||||
setExpandedStageId(legs[0].id);
|
||||
}
|
||||
}, [legs]);
|
||||
|
||||
return (
|
||||
<div id="itinerary-timeline-print-zone" className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
||||
<div className="px-2 pt-4">
|
||||
<div id="itinerary-timeline-print-zone" className="timeline-scroll-container itinerary-timeline-container">
|
||||
{legs.length === 0 ? (
|
||||
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
||||
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
@@ -220,9 +231,12 @@ export const ItineraryTimeline = ({
|
||||
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||
|
||||
return (
|
||||
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
{/* Leg Header */}
|
||||
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
|
||||
<section key={leg.id} className={`folder-node-wrapper ${expandedStageId === leg.id ? 'expanded' : 'collapsed'}`}>
|
||||
{/* Folder header row - clickable to toggle exclusive expansion */}
|
||||
<div
|
||||
onClick={() => toggleStageExpanded(leg.id)}
|
||||
className="folder-header-row animate-in fade-in slide-in-from-bottom-4 duration-300 hover:bg-gray-50/50 transition-colors"
|
||||
>
|
||||
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm shrink-0">
|
||||
@@ -245,24 +259,24 @@ export const ItineraryTimeline = ({
|
||||
</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 && (
|
||||
<>
|
||||
<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"
|
||||
title="Thêm địa điểm vào chặng này"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</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"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</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"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -298,9 +312,13 @@ export const ItineraryTimeline = ({
|
||||
)} {/* Only show optimize button if canEdit */}
|
||||
</div>
|
||||
|
||||
{/* Vertical Line for the whole leg */}
|
||||
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
|
||||
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
|
||||
{/* Scrollable content body with proper z-index layering */}
|
||||
<div className="child-nodes-list-wrapper">
|
||||
{/* Folder child content box - Grid accordion for exclusive expansion */}
|
||||
<div className={`folder-child-content-box ${expandedStageId === leg.id ? 'expanded' : ''}`}>
|
||||
<div className="child-nodes-list relative">
|
||||
{/* Vertical Line for the whole leg - Dynamic height */}
|
||||
<div className="absolute left-6 top-16 w-0.5 bg-blue-100 -z-0 h-full" />
|
||||
|
||||
<div className="ml-2">
|
||||
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
|
||||
@@ -522,8 +540,11 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div> {/* End of ml-2 wrapper */}
|
||||
</div> {/* End of child-nodes-list */}
|
||||
</div> {/* End of folder-child-content-box */}
|
||||
</div> {/* End of child-nodes-list-wrapper */}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -546,7 +567,6 @@ export const ItineraryTimeline = ({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Khai báo số chặng (Popover) */}
|
||||
{isLegCountModalOpen && (
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
interface TourChatProps {
|
||||
tourId: string;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false }) => {
|
||||
const notify = useNotification();
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
@@ -416,21 +417,24 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// currentUserId is defined at the top
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-150 rounded-2xl shadow-lg overflow-hidden flex flex-col h-[500px]">
|
||||
{/* Chat Header */}
|
||||
<div className="p-4 border-b border-gray-150 flex items-center gap-2 bg-gray-50/50">
|
||||
<div className={`chat-viewport-wrapper flex flex-col !overflow-hidden !w-full ${embedded ? 'h-full flex-1' : 'h-[100dvh]'} bg-white`}>
|
||||
{/* Fixed Top Layout Block - Header and Tabs Container */}
|
||||
<div className="fixed-top-layout-block flex-shrink-0 !w-full z-50 bg-white border-b border-gray-200">
|
||||
{/* Chat Header - Only show when not embedded */}
|
||||
{!embedded && (
|
||||
<div className="p-4 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-800">Trò chuyện nhóm hành trình</h3>
|
||||
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đổi thông tin, hình ảnh và định vị giữa các thành viên</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-3 min-h-0 bg-slate-50/20">
|
||||
{/* Chat History Scroll Viewport - ONLY scrollable area */}
|
||||
<div className="chat-history-scroll-viewport flex-1 min-h-0 !overflow-y-auto !overflow-x-hidden p-4 flex flex-col gap-3 bg-slate-50/20">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 text-xs gap-1.5">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" /> Đang tải tin nhắn...
|
||||
@@ -468,7 +472,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${
|
||||
isMe
|
||||
? 'bg-blue-600 text-white rounded-tr-none'
|
||||
: 'bg-white text-gray-700 rounded-tl-none border border-gray-150 shadow-sm'
|
||||
: 'bg-white text-gray-700 rounded-tl-none border border-gray-200 shadow-sm'
|
||||
}`}>
|
||||
{/* Attachment Image */}
|
||||
{msg.attachmentUrl && (
|
||||
@@ -498,7 +502,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${
|
||||
isMe
|
||||
? 'bg-blue-700 border-blue-600 text-blue-100 hover:bg-blue-800'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-750 hover:bg-gray-200'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
|
||||
@@ -525,7 +529,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
|
||||
{/* Previews (Image & GPS Location) */}
|
||||
{(imagePreview || attachedLocation) && (
|
||||
<div className="px-4 py-2 border-t border-gray-150 bg-gray-50/80 flex flex-wrap gap-2">
|
||||
<div className="px-4 py-2 border-t border-gray-200 bg-gray-50/80 flex flex-wrap gap-2 !flex-shrink-0">
|
||||
{imagePreview && (
|
||||
<div className="relative w-16 h-16 rounded-lg overflow-hidden border border-gray-200 shadow-sm">
|
||||
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
@@ -557,8 +561,8 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Input wrapper */}
|
||||
<div className="relative">
|
||||
{/* Locked Chat Input Footer */}
|
||||
<div className="locked-chat-input-footer !flex-shrink-0 !w-full !border-t border-gray-200 bg-white !z-40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 12px)' }}>
|
||||
{/* Mention list dropdown */}
|
||||
{showMentionList && filteredParticipants.length > 0 && (
|
||||
<div
|
||||
@@ -589,7 +593,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
{/* Chat Input form */}
|
||||
<form
|
||||
onSubmit={handleSendMessage}
|
||||
className="p-3 border-t border-gray-150 bg-gray-50 flex gap-2 items-center"
|
||||
className="p-3 flex gap-2 items-center !w-full relative"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
|
||||
@@ -1,10 +1,299 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--main-header-height: 56px;
|
||||
--sub-nav-height: 48px;
|
||||
--combined-top-height: 104px;
|
||||
}
|
||||
|
||||
html, body, #root, .app-container {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: 100vw !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Fix viewport overflow on all container levels */
|
||||
body, html {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.itinerary-timeline-page {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.main-top-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: var(--main-header-height);
|
||||
z-index: 50;
|
||||
background-color: #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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1683,7 +1683,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
|
||||
{/* TAB 4: REALTIME CHAT */}
|
||||
{activeTab === 'chats' && (
|
||||
<div className="flex-1 w-full bg-slate-900/40 border-0 md:border border-slate-800/80 md:rounded-3xl overflow-hidden flex animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="flex flex-col h-[100dvh] md:h-auto w-full bg-slate-900/40 border-0 md:border border-slate-800/80 md:rounded-3xl overflow-hidden flex animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
|
||||
{/* Chats List sidebar: show if not mobile OR if mobile and no active chat user */}
|
||||
{(!isMobile || !activeChatUser) && (
|
||||
|
||||
@@ -2003,9 +2003,9 @@ export const TourDetailPage = ({
|
||||
</div>
|
||||
|
||||
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
|
||||
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`}>
|
||||
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} px-4 pb-24`}>
|
||||
{/* Tab Switcher */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20">
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-[60px] z-40">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -2013,7 +2013,7 @@ export const TourDetailPage = ({
|
||||
setActiveTab(tab.id as any);
|
||||
if (tab.id === 'chat') setUnreadChatCount(0);
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all relative ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
|
||||
className={`flex-1 flex items-center justify-center py-2 rounded-xl text-xs font-semibold transition-all relative ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||
@@ -3004,8 +3004,17 @@ export const TourDetailPage = ({
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'chat' && currentTour && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||
<TourChat tourId={currentTour.id} />
|
||||
<div className="fixed left-0 right-0 bottom-0 flex flex-col bg-white z-[15]" style={{top: 'calc(60px + 5px)'}}>
|
||||
{/* Chat Group Header Banner - Fixed below nav + tabs, sticky to title */}
|
||||
<div className="flex-shrink-0 p-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-800">Trò chuyện trong nhóm</h3>
|
||||
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đổi thông tin, hình ảnh và định vị giữa các thành viên</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* TourChat with proper scrollable area */}
|
||||
<TourChat tourId={currentTour.id} embedded={true} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user