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
+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',