fix: sửa lỗi hiển thị logo trên trang pdf

This commit is contained in:
2026-06-20 17:02:26 +07:00
parent e34d197dd0
commit ae97f061e8
21 changed files with 978 additions and 328 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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-CtrQmjY1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i0kJVU1C.css">
<script type="module" crossorigin src="/assets/index-C3QeRTiI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-_0oFafPI.css">
</head>
<body>
<div id="root"></div>
+27 -13
View File
@@ -132,22 +132,36 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
[formData.latitude, formData.longitude]
);
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng đó làm tham chiếu)
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng trước/hiện tại làm tham chiếu tiếp nối)
useEffect(() => {
if (isOpen && !editingLocation && formData.legId && !formData.name) {
const selectedLeg = legs.find(l => l.id === formData.legId);
if (selectedLeg && selectedLeg.locations && selectedLeg.locations.length > 0) {
// Di chuyển đến địa điểm cuối cùng của chặng để người dùng thấy điểm nối tiếp
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Nếu chặng chưa có điểm nào, mặc định dùng vị trí trung tâm hiện tại của tour
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
if (selectedLeg) {
if (selectedLeg.locations && selectedLeg.locations.length > 0) {
// Di chuyển đến địa điểm cuối cùng của chặng hiện tại để người dùng thấy điểm nối tiếp
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Chặng trống -> Lấy địa điểm cuối của chặng trước làm tọa độ tiếp nối
const currentLegIdx = legs.findIndex(l => l.id === formData.legId);
const prevLeg = currentLegIdx > 0 ? legs[currentLegIdx - 1] : null;
if (prevLeg && prevLeg.locations && prevLeg.locations.length > 0) {
const lastLoc = prevLeg.locations[prevLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Nếu không có chặng trước hoặc chặng trước trống, mặc định dùng vị trí trung tâm hiện tại của tour
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
}
}
}
}
}, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
@@ -484,7 +498,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
<option value="">-- Chọn người thanh toán --</option>
{currentTour?.participants?.map((p: any) => {
{currentTour?.participants?.filter((p: any) => p.user)?.map((p: any) => {
const name = p.user?.name;
const email = p.user?.email;
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
+29 -5
View File
@@ -15,7 +15,7 @@ interface AddMemberModalProps {
isPublicView?: boolean; // New prop to indicate public view
}
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole, isPublicView }) => {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -54,8 +54,12 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
useEffect(() => {
if (!isOpen) return;
fetchUsers();
}, [isOpen]);
const delayDebounceFn = setTimeout(() => {
fetchUsers();
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [query, isOpen]);
useEffect(() => {
if (!isOpen) {
@@ -158,9 +162,9 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<div className="p-5 space-y-4">
<div>
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p>
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.filter(p => p.user).length})</p>
<div className="flex flex-wrap gap-3">
{participants.map((p) => {
{participants.filter(p => p.user).map((p) => {
const rawToken = localStorage.getItem('token');
let currentUserId: string | null = null;
try {
@@ -255,6 +259,26 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
)}
<div className="space-y-2">
<div className="relative mb-2">
<input
type="text"
placeholder="Tìm kiếm theo tên hoặc địa chỉ email..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-10 pr-10 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-200 rounded-full text-gray-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{fetchError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError}
File diff suppressed because one or more lines are too long
@@ -250,7 +250,7 @@ export const ItineraryTimeline = ({
{canEdit && (
<>
<button
onClick={() => onAddLocation?.(leg.id)}
onClick={() => 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"
>
+7 -2
View File
@@ -402,7 +402,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<a
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center"
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center relative"
onClick={(e) => {
e.preventDefault();
setIsFullscreen(true);
@@ -411,8 +411,13 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<img
src={photo.imageUrl}
alt="Public Map Upload"
className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none"
className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none"
draggable={false}
/>
{/* Shield overlay to prevent Save Image As on right-click / long press for non-owners */}
{!isAuthorized && (
<div className="absolute inset-0 bg-transparent select-none z-10" />
)}
</a>
</div>
+4 -2
View File
@@ -546,8 +546,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
className: 'custom-photo-bubble',
html: `
<a href="${window.location.origin}/api/v1/public-photos/${latestPhoto.id}/share" onclick="event.preventDefault();" class="relative group block">
<div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover" />
<div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110 relative">
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover select-none" draggable="false" />
<div class="absolute inset-0 bg-transparent select-none z-10"></div>
</div>
<div class="absolute -bottom-1 -right-1 bg-emerald-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
📸
@@ -604,6 +605,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const newTourNote = {
id: Date.now().toString(),
tourId: tour.id,
title: `Ghi chú của hành trình: ${tour.title}`,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${tour.title}</strong> của bạn tại đây...</p>`,
createdAt: new Date().toISOString()
+116 -9
View File
@@ -701,6 +701,22 @@ export const TourDetailPage = ({
const [commentLocationName, setCommentLocationName] = useState('');
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
// State cho Modal ghi chú nhanh
const [quickNoteLocName, setQuickNoteLocName] = useState<string | null>(null);
const [quickNoteInput, setQuickNoteInput] = useState('');
// Đồng bộ hóa các ô nhập dữ liệu cài đặt với currentTour khi tour tải/cập nhật
useEffect(() => {
if (currentTour) {
setTitleInput(currentTour.title ?? '');
setDescriptionInput(currentTour.description ?? '');
setAdultCountInput(currentTour.adultCount ?? 0);
setChildCountInput(currentTour.childCount ?? 0);
setChildDiscountInput(currentTour.childDiscount ?? 0);
setTagsInput(currentTour.tags ?? []);
}
}, [currentTour]);
// State tìm kiếm cho chế độ Bản đồ trong Tab Lộ trình
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
@@ -1201,6 +1217,35 @@ export const TourDetailPage = ({
}
};
// Đồng bộ tiêu đề ghi chú khi đổi tên chuyến đi
const syncTourNoteTitle = (tourId: string, newTitle: string) => {
const savedNotes = localStorage.getItem('my_journey_notes');
if (!savedNotes) return;
try {
let notes = JSON.parse(savedNotes);
if (Array.isArray(notes)) {
let updated = false;
const oldTitle = currentTour?.title || '';
notes = notes.map((n: any) => {
if (n.tourId === tourId || n.title === `Ghi chú của hành trình: ${oldTitle}`) {
n.tourId = tourId;
n.title = `Ghi chú của hành trình: ${newTitle}`;
if (oldTitle && n.content) {
n.content = n.content.split(`<strong>${oldTitle}</strong>`).join(`<strong>${newTitle}</strong>`);
}
updated = true;
}
return n;
});
if (updated) {
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
}
}
} catch (e) {
console.error("Error syncing tour note title:", e);
}
};
// Hàm xử lý cập nhật số lượng người tham gia
const handleUpdateTourInfo = async () => {
if (!currentTour) return;
@@ -1218,31 +1263,40 @@ export const TourDetailPage = ({
message: 'Đã cập nhật thông tin chuyến đi.',
type: 'success'
});
if (titleInput !== currentTour.title) {
syncTourNoteTitle(currentTour.id, titleInput);
}
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
} catch (error: any) {
notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật thông tin.', type: 'error' });
}
};
// Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour
// Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour
const handleQuickNote = (locationName: string) => {
if (isPublicView) return;
setQuickNoteLocName(locationName);
setQuickNoteInput('');
};
const content = window.prompt(`Ghi chú nhanh cho địa điểm: ${locationName}`);
if (!content || !content.trim()) return;
// Hàm xử lý submit ghi chú nhanh từ modal
const submitQuickNote = () => {
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour) return;
const content = quickNoteInput;
const storedUser = localStorage.getItem('user');
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
const userName = user.name || 'Thành viên';
const now = new Date().toLocaleString('vi-VN');
const noteTitle = `Ghi chú của hành trình: ${currentTour?.title}`;
const noteTitle = `Ghi chú của hành trình: ${currentTour.title}`;
const savedNotes = localStorage.getItem('my_journey_notes');
let notes = [];
try {
notes = savedNotes ? JSON.parse(savedNotes) : [];
} catch (e) { notes = []; }
let targetNote = notes.find((n: any) => n.title === noteTitle);
let targetNote = notes.find((n: any) => n.tourId === currentTour.id || n.title === noteTitle);
// Tạo khối nội dung dạng "Textbox" chuyên nghiệp
const newContentLine = `
@@ -1250,18 +1304,21 @@ export const TourDetailPage = ({
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} 👤 ${userName}</span>
</div>
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${locationName}:</strong> ${content}</p>
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${quickNoteLocName}:</strong> ${content}</p>
</div>
<p></p>
`;
if (targetNote) {
targetNote.tourId = currentTour.id;
targetNote.title = noteTitle;
targetNote.content += newContentLine;
} else {
const newNote = {
id: Date.now().toString(),
tourId: currentTour.id,
title: noteTitle,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour?.title}</strong> của bạn tại đây...</p>` + newContentLine,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour.title}</strong> của bạn tại đây...</p>` + newContentLine,
createdAt: new Date().toISOString()
};
notes.unshift(newNote);
@@ -1269,6 +1326,8 @@ export const TourDetailPage = ({
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
setQuickNoteLocName(null);
setQuickNoteInput('');
};
// Hàm xử lý xóa Tour vĩnh viễn
@@ -1352,7 +1411,7 @@ export const TourDetailPage = ({
const tourInfo = {
title: currentTour?.title || "Hành trình khám phá TP.HCM",
date: tourDateDisplay,
membersCount: currentTour?.participants?.length || 0,
membersCount: currentTour?.participants?.filter((p: any) => p.user)?.length || 0,
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
coverImage: getMostLikedPhoto(currentTour?.photos || [])?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
};
@@ -1448,7 +1507,7 @@ export const TourDetailPage = ({
{/* Member Avatars Stack */}
<div className="flex items-center gap-2 mt-4">
<div className="flex flex-wrap gap-2"> {/* Always show participants */}
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
{currentTour?.participants?.filter((p: any) => p.user)?.slice(0, 5).map((p: any, i: number) => (
<button
key={p.userId || i}
onClick={() => {
@@ -2682,6 +2741,54 @@ export const TourDetailPage = ({
/>
)}
{/* Quick Note Modal */}
{quickNoteLocName && (
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={() => setQuickNoteLocName(null)} />
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-gray-900">Ghi chú nhanh</h3>
<button onClick={() => setQuickNoteLocName(null)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<div className="space-y-4">
<p className="text-sm font-bold text-gray-700">
📍 Đa điểm: <span className="text-blue-600">{quickNoteLocName}</span>
</p>
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Nội dung ghi chú</label>
<textarea
value={quickNoteInput}
onChange={(e) => setQuickNoteInput(e.target.value)}
placeholder="Nhập nội dung ghi chú nhanh..."
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none"
autoFocus
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-8">
<button
onClick={() => setQuickNoteLocName(null)}
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
>
Hủy
</button>
<button
onClick={submitQuickNote}
disabled={!quickNoteInput.trim()}
className="py-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
>
Lưu
</button>
</div>
</div>
</div>
)}
{/* Member Detail Popover */}
{isMemberDetailOpen && selectedMember && (
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
+17
View File
@@ -25,6 +25,7 @@ interface TourState {
optimizeRouting: (legId: string) => Promise<void>;
addMember: (tourId: string, member: { userId: string; role?: string }) => Promise<any>;
removeMember: (tourId: string, userId: string) => Promise<void>;
updateMemberFamilyCount: (tourId: string, userId: string, adultCount: number, childCount: number) => Promise<void>;
setActiveLegId: (id: string | null) => void;
setMapCenter: (pos: [number, number]) => void;
fetchTour: (id: string) => Promise<void>;
@@ -329,6 +330,22 @@ export const useTourStore = create<TourState>((set, get) => ({
const { currentTour } = get();
if (currentTour) get().fetchTour(currentTour.id);
},
updateMemberFamilyCount: async (tourId: string, userId: string, adultCount: number, childCount: number) => {
const response = await fetch(`/api/v1/tours/${tourId}/members/${userId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ adultCount, childCount }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || data.error || 'Lỗi khi cập nhật thành viên');
}
const { currentTour } = get();
if (currentTour) get().fetchTour(currentTour.id);
},
createJoinRequest: async (tourId: string, userId?: string) => {
const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
method: 'POST',