fix: lỗi hiển thị trong itineraryTimeline

This commit is contained in:
2026-06-23 11:40:41 +07:00
parent a65be48d36
commit 6b15e7ff02
17 changed files with 625 additions and 135 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
+37 -17
View File
@@ -75,6 +75,14 @@ export const ItineraryTimeline = ({
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
const [commentLocationId, setCommentLocationId] = useState('');
const [commentLocationName, setCommentLocationName] = useState('');
// State to track single expanded stage (exclusive single-expansion mode)
const [expandedStageId, setExpandedStageId] = useState<string | null>(legs.length > 0 ? legs[0]?.id : null);
const toggleStageExpanded = (legId: string) => {
// Exclusive mode: if clicking the same stage, close it. Otherwise, open only the clicked one.
setExpandedStageId(prevId => prevId === legId ? null : legId);
};
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
const handleCommentIncrement = (locationId: string) => {
@@ -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,12 +312,16 @@ 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ó */}
<div className="ml-2">
{/* 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) && (
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
<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> {/* 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 && (
+23 -19
View File
@@ -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">
<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 đnh vị giữa các thành viên</p>
</div>
<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 đ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"
+289
View 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;
}
}
+3 -3
View File
@@ -1681,9 +1681,9 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
</div>
)}
{/* 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">
{/* TAB 4: REALTIME CHAT */}
{activeTab === 'chats' && (
<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) && (
+14 -5
View File
@@ -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 đ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>