import React, { useState, useRef, useEffect } from 'react'; import { Compass, Map as MapIcon, Camera, ShieldAlert, Image as ImageIcon, X } from 'lucide-react'; import { LoginModal } from '../components/LoginModal'; import { ReportBusinessModal } from '../components/ReportBusinessModal'; import { TagSelectModal } from '../components/TagSelectModal'; import { MapProfileDropdown } from '../components/MapProfileDropdown'; import { ProfileSettingsModal } from '../components/ProfileSettingsModal'; import { MyToursModal } from '../components/modals/MyToursModal'; import { PhotoGalleryModal as MyPhotosModal } from '../components/modals/PhotoGalleryModal'; import { LiveChatModal } from '../components/modals/LiveChatModal'; import { FriendsManagerModal } from '../components/modals/FriendsManagerModal'; import { AppDownloadBanner } from '../components/layout/AppDownloadBanner'; import { useNotification } from '@/hooks/useNotification'; import { processImageModeration } from '../hooks/useImageModeration'; import { useTranslation } from '../hooks/useTranslation'; import { compressImage } from '../utils/image'; interface LandingPageProps { onContinue?: () => void; onGoToSignup?: () => void; onGoToMap?: () => void; onLoginSuccess?: (user: any) => void; isInitialSetup?: boolean; user?: any; onLogout?: () => void; onGoToDashboard?: (tab?: 'tours' | 'connections' | 'photos' | 'chats') => void; onOpenNavigation?: (payload: any) => void; } // Helper to extract GPS location from EXIF tags in JPEG/JPG files function getExifGps(file: File): Promise<{ latitude: number; longitude: number } | null> { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => { try { const buffer = e.target?.result as ArrayBuffer; const view = new DataView(buffer); if (view.byteLength < 4) { resolve(null); return; } if (view.getUint16(0, false) !== 0xFFD8) { resolve(null); return; } let offset = 2; const length = view.byteLength; while (offset < length - 2) { const marker = view.getUint16(offset, false); if (marker === 0xFFE1) { const app1Length = view.getUint16(offset + 2, false); if (offset + 4 + app1Length > length) { resolve(null); return; } const exifHeader = view.getUint32(offset + 4, false); if (exifHeader === 0x45786966) { const tiffOffset = offset + 10; const bigEndian = view.getUint16(tiffOffset, false) === 0x4D4D; if (view.getUint16(tiffOffset + 2, bigEndian) !== 0x002A) { resolve(null); return; } const firstIFD = view.getUint32(tiffOffset + 4, bigEndian); let ifdOffset = tiffOffset + firstIFD; if (ifdOffset + 2 > length) { resolve(null); return; } const numEntries = view.getUint16(ifdOffset, bigEndian); let gpsInfoOffset = 0; for (let i = 0; i < numEntries; i++) { const entryOffset = ifdOffset + 2 + i * 12; if (entryOffset + 12 > length) break; const tag = view.getUint16(entryOffset, bigEndian); if (tag === 0x8825) { gpsInfoOffset = view.getUint32(entryOffset + 8, bigEndian); break; } } if (gpsInfoOffset) { const gpsIFDOffset = tiffOffset + gpsInfoOffset; if (gpsIFDOffset + 2 > length) { resolve(null); return; } const numGpsEntries = view.getUint16(gpsIFDOffset, bigEndian); let latRef = 'N'; let lonRef = 'E'; let latVal: number[] = []; let lonVal: number[] = []; for (let i = 0; i < numGpsEntries; i++) { const entryOffset = gpsIFDOffset + 2 + i * 12; if (entryOffset + 12 > length) break; const tag = view.getUint16(entryOffset, bigEndian); if (tag === 1) { latRef = String.fromCharCode(view.getUint8(entryOffset + 8)); } else if (tag === 2) { const offsetVal = view.getUint32(entryOffset + 8, bigEndian); latVal = readRationalArray(view, tiffOffset + offsetVal, bigEndian, length); } else if (tag === 3) { lonRef = String.fromCharCode(view.getUint8(entryOffset + 8)); } else if (tag === 4) { const offsetVal = view.getUint32(entryOffset + 8, bigEndian); lonVal = readRationalArray(view, tiffOffset + offsetVal, bigEndian, length); } } if (latVal.length === 3 && lonVal.length === 3) { const latitude = convertDMSToDD(latVal[0], latVal[1], latVal[2], latRef); const longitude = convertDMSToDD(lonVal[0], lonVal[1], lonVal[2], lonRef); resolve({ latitude, longitude }); return; } } } break; } else if (marker >= 0xFFD0 && marker <= 0xFFD9) { offset += 2; } else { const blockLength = view.getUint16(offset + 2, false); offset += 2 + blockLength; } } resolve(null); } catch (err) { console.error('Error parsing EXIF:', err); resolve(null); } }; reader.onerror = () => resolve(null); reader.readAsArrayBuffer(file.slice(0, 128 * 1024)); }); } function readRationalArray(view: DataView, offset: number, bigEndian: boolean, maxLen: number): number[] { const values = []; if (offset + 24 > maxLen) return []; for (let i = 0; i < 3; i++) { const num = view.getUint32(offset + i * 8, bigEndian); const den = view.getUint32(offset + i * 8 + 4, bigEndian); values.push(den === 0 ? 0 : num / den); } return values; } function convertDMSToDD(degrees: number, minutes: number, seconds: number, ref: string): number { let dd = degrees + minutes / 60 + seconds / 3600; if (ref === 'S' || ref === 'W') { dd = -dd; } return dd; } export const LandingPage: React.FC = ({ onGoToSignup, onGoToMap, onLoginSuccess, user, onLogout, onOpenNavigation, }) => { const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); const [isProfileSettingsOpen, setIsProfileSettingsOpen] = useState(false); const [isMyToursOpen, setIsMyToursOpen] = useState(false); const [isMyPhotosOpen, setIsMyPhotosOpen] = useState(false); const [isChatOpen, setIsChatOpen] = useState(false); const [isFriendsOpen, setIsFriendsOpen] = useState(false); const [chatTargetUserId, setChatTargetUserId] = useState(null); const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); const [isPhotoSourceModalOpen, setIsPhotoSourceModalOpen] = useState(false); const fileInputRef = useRef(null); const cameraInputRef = useRef(null); const galleryInputRef = useRef(null); const [pendingPhotoFile, setPendingPhotoFile] = useState(null); const [pendingPhotoLocation, setPendingPhotoLocation] = useState<{ latitude: number; longitude: number } | null>(null); const [photoPreviewUrl, setPhotoPreviewUrl] = useState(''); const notify = useNotification(); const { t } = useTranslation(); const [publicPhotos, setPublicPhotos] = useState([]); const [trustedUsers, setTrustedUsers] = useState([]); const [blacklist, setBlacklist] = useState([]); const [currentBgIndex, setCurrentBgIndex] = useState(0); const [bg1, setBg1] = useState('/background.avif'); const [bg2, setBg2] = useState(''); const [fade1, setFade1] = useState(true); const [fade2, setFade2] = useState(false); const [activeSlot, setActiveSlot] = useState<1 | 2>(1); const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token'); useEffect(() => { const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif'; if (!nextUrl) return; const currentUrl = activeSlot === 1 ? bg1 : bg2; if (nextUrl === currentUrl) return; if (activeSlot === 1) { setBg2(nextUrl); setFade2(true); setFade1(false); setActiveSlot(2); } else { setBg1(nextUrl); setFade1(true); setFade2(false); setActiveSlot(1); } }, [currentBgIndex, publicPhotos]); const fetchPublicPhotos = async () => { try { const res = await fetch('/api/v1/public-photos'); if (res.ok) { const data = await res.json(); const sortedData = data.sort((a: any, b: any) => { const likesA = a.metadata?.likedUserIds?.length || 0; const likesB = b.metadata?.likedUserIds?.length || 0; return likesB - likesA; }); setPublicPhotos(sortedData); } } catch (e) { console.error('Lỗi khi tải ảnh công khai:', e); } }; const fetchTrustedUsers = async () => { try { const res = await fetch('/api/v1/users/trusted'); if (res.ok) { const data = await res.json(); setTrustedUsers(data); } } catch (e) { console.error('Lỗi khi tải thành viên uy tín:', e); } }; const fetchBlacklist = async () => { try { const res = await fetch('/api/v1/reports/blacklist'); if (res.ok) { const data = await res.json(); setBlacklist(data); } } catch (e) { console.error('Lỗi khi tải blacklist:', e); } }; // Update Open Graph meta tags when public photos are fetched useEffect(() => { if (publicPhotos.length > 0) { const mainPhoto = publicPhotos[0]; const photoUrl = mainPhoto.imageUrl || mainPhoto.originalUrl || '/background.avif'; const photoTitle = mainPhoto.metadata?.title || 'Travel Planner - Khám phá chuyến đi tuyệt vời'; const photoDescription = mainPhoto.metadata?.description || `Được chia sẻ bởi ${mainPhoto.uploader?.name || 'một thành viên'}. Khám phá những hành trình tuyệt vời trên Travel Planner.`; // Update og:image let ogImage = document.querySelector('meta[property="og:image"]'); if (!ogImage) { ogImage = document.createElement('meta'); ogImage.setAttribute('property', 'og:image'); document.head.appendChild(ogImage); } ogImage.setAttribute('content', `https://yotrip.labz.io.vn${photoUrl}`); // Update og:title let ogTitle = document.querySelector('meta[property="og:title"]'); if (!ogTitle) { ogTitle = document.createElement('meta'); ogTitle.setAttribute('property', 'og:title'); document.head.appendChild(ogTitle); } ogTitle.setAttribute('content', photoTitle); // Update og:description let ogDescription = document.querySelector('meta[property="og:description"]'); if (!ogDescription) { ogDescription = document.createElement('meta'); ogDescription.setAttribute('property', 'og:description'); document.head.appendChild(ogDescription); } ogDescription.setAttribute('content', photoDescription); // Update twitter:image let twitterImage = document.querySelector('meta[name="twitter:image"]'); if (!twitterImage) { twitterImage = document.createElement('meta'); twitterImage.setAttribute('name', 'twitter:image'); document.head.appendChild(twitterImage); } twitterImage.setAttribute('content', `https://yotrip.labz.io.vn${photoUrl}`); } }, [publicPhotos]); useEffect(() => { fetchPublicPhotos(); fetchTrustedUsers(); fetchBlacklist(); }, []); useEffect(() => { if (publicPhotos.length <= 1) return; const interval = setInterval(() => { setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length); }, 12000); return () => clearInterval(interval); }, [publicPhotos]); const handleFileChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' }); try { // Nén ảnh trước const compressedFile = await compressImage(file); // 0. Kiểm duyệt ảnh const moderationResult = await processImageModeration(compressedFile); if (moderationResult.blocked) { notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' }); return; } const processedFile = moderationResult.file; // Determine image upload coordinates based on priority checklist: let finalLat: number | null = null; let finalLng: number | null = null; // 1. Read EXIF GPS metadata location from the uploaded image itself try { const exifLoc = await getExifGps(file); if (exifLoc) { finalLat = exifLoc.latitude; finalLng = exifLoc.longitude; console.log('[Upload Location] Priority 1: EXIF data coordinates found:', finalLat, finalLng); } } catch (e) { console.error('[Upload Location] Error reading EXIF from file:', e); } // 2. Get current mobile/device GPS position of the user if (finalLat === null || finalLng === null) { try { const pos = await Promise.race([ new Promise((resolve) => { if (!navigator.geolocation) { resolve(null); } else { navigator.geolocation.getCurrentPosition( (p) => resolve(p), () => resolve(null), { timeout: 4000, enableHighAccuracy: true } ); } }), new Promise((resolve) => setTimeout(() => resolve(null), 4500)) ]); if (pos && pos.coords) { finalLat = pos.coords.latitude; finalLng = pos.coords.longitude; console.log('[Upload Location] Priority 2: GPS coordinates found:', finalLat, finalLng); } } catch (e) { console.error('[Upload Location] Error acquiring current GPS:', e); } } // 3. Get last viewed map viewport center coordinates if (finalLat === null || finalLng === null) { const lastViewStateStr = localStorage.getItem('map_view_state'); if (lastViewStateStr) { try { const lastViewState = JSON.parse(lastViewStateStr); if (lastViewState && Array.isArray(lastViewState.center) && lastViewState.center.length === 2) { finalLat = Number(lastViewState.center[0]); finalLng = Number(lastViewState.center[1]); console.log('[Upload Location] Priority 3: Last viewed map viewport center used:', finalLat, finalLng); } } catch (e) { console.error('[Upload Location] Error parsing map_view_state:', e); } } } // 4. Default fallback location coordinates if (finalLat === null || finalLng === null) { finalLat = 10.7769; finalLng = 106.7009; console.log('[Upload Location] Priority 4: Using default fallback coordinates:', finalLat, finalLng); } // Save file and resolved coordinates into state setPendingPhotoFile(processedFile); setPendingPhotoLocation({ latitude: finalLat, longitude: finalLng }); // Tạo preview URL cho ảnh const previewUrl = URL.createObjectURL(processedFile); setPhotoPreviewUrl(previewUrl); setIsTagsModalOpen(true); } catch (error: any) { notify({ title: 'Lỗi', message: error.message, type: 'error' }); } finally { // Reset input để có thể chọn lại cùng 1 file if (event.target) event.target.value = ''; } }; const handleConfirmTags = async (selectedTags: string[]) => { if (!pendingPhotoFile) return; setIsTagsModalOpen(false); notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' }); try { // 1. Kiểm tra xem người dùng đã đăng nhập chưa const token = localStorage.getItem('token'); const guestToken = localStorage.getItem('guest_token'); const isRealUser = token && !guestToken; let uploadToken = token; // 2. Nếu là khách, tạo tài khoản khách và lấy token if (!isRealUser) { let currentGuestToken = guestToken; let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null'); if (!currentGuestToken) { const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' }); if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.'); const guestData = await guestRes.json(); currentGuestToken = guestData.access_token; guestUser = guestData.user; localStorage.setItem('guest_token', currentGuestToken!); localStorage.setItem('guest_user', JSON.stringify(guestUser)); } uploadToken = currentGuestToken; } // 3. Tải ảnh lên const formData = new FormData(); formData.append('images', pendingPhotoFile); if (pendingPhotoLocation) { formData.append('latitude', pendingPhotoLocation.latitude.toString()); formData.append('longitude', pendingPhotoLocation.longitude.toString()); } // Thêm tags vào formData if (selectedTags.length > 0) { formData.append('tags', JSON.stringify(selectedTags)); } let uploadRes = await fetch('/api/v1/photos/upload-anonymous', { method: 'POST', headers: { 'Authorization': `Bearer ${uploadToken!}` }, body: formData, }); if (uploadRes.status === 401 && !isRealUser) { console.warn('Guest token invalid or expired. Creating a new guest user and retrying...'); localStorage.removeItem('guest_token'); localStorage.removeItem('guest_user'); const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' }); if (!guestRes.ok) throw new Error('Không thể tạo lại phiên khách.'); const guestData = await guestRes.json(); const newGuestToken = guestData.access_token; const guestUser = guestData.user; localStorage.setItem('guest_token', newGuestToken!); localStorage.setItem('guest_user', JSON.stringify(guestUser)); uploadRes = await fetch('/api/v1/photos/upload-anonymous', { method: 'POST', headers: { 'Authorization': `Bearer ${newGuestToken!}` }, body: formData, }); } if (!uploadRes.ok) { const errorData = await uploadRes.json(); throw new Error(errorData.message || 'Tải ảnh thất bại.'); } notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' }); // Xóa pendingInviteToken và parameter token khỏi URL để tránh tự động chuyển sang JoinTourPage localStorage.removeItem('pendingInviteToken'); const url = new URL(window.location.href); url.searchParams.delete('token'); window.history.replaceState({}, '', url.toString()); // Cập nhật lại danh sách ảnh lập tức await fetchPublicPhotos(); setCurrentBgIndex(0); // Xóa pending data setPendingPhotoFile(null); setPendingPhotoLocation(null); if (photoPreviewUrl) { URL.revokeObjectURL(photoPreviewUrl); setPhotoPreviewUrl(''); } } catch (error: any) { notify({ title: 'Lỗi', message: error.message, type: 'error' }); // Cleanup on error too setPendingPhotoFile(null); setPendingPhotoLocation(null); if (photoPreviewUrl) { URL.revokeObjectURL(photoPreviewUrl); setPhotoPreviewUrl(''); } } }; return (
{/* Background Image with Horizontal Panning */}
{/* Active image for panning */}
{bg1 && ( { if (!isLoggedIn) e.preventDefault(); }} onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }} className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : '' }`} alt="Travel Background 1" /> )}
{bg2 && ( { if (!isLoggedIn) e.preventDefault(); }} onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }} className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : '' }`} alt="Travel Background 2" /> )}
{/* Style for horizontal panning */} {/* Swipe Indicator Arrows - for image switching */} {publicPhotos.length > 1 && ( <> )} {/* Photo Progress Dots */} {publicPhotos.length > 1 && (
{publicPhotos.slice(0, 20).map((_, idx) => (
)} {/* Top Bar - Thanh điều hướng trên cùng */}
YoTrip
{user && !localStorage.getItem('guest_token') && ( )} setIsProfileSettingsOpen(true)} onOpenCreateTour={() => onGoToMap?.()} onOpenReport={() => setIsReportModalOpen(true)} onOpenLogin={() => setIsLoginModalOpen(true)} onOpenMyPhotos={() => setIsMyPhotosOpen(true)} onOpenMyTours={() => setIsMyToursOpen(true)} onOpenFriends={() => setIsFriendsOpen(true)} />
{/* Floating Trusted Leaderboard Panel (Left Side on Desktop) */}

🏆 {t('trustedMembers')}

{trustedUsers.length === 0 ? (
Chưa có đánh giá nào.
) : ( trustedUsers.map((u, idx) => (
{u.avatar ? ( Avatar ) : ( {u.name.charAt(0)} )}
{idx + 1}
{u.name}
★ {u.averageScore} ({u.ratingCount})
)) )}
{/* Floating Blacklist Panel (Right Side on Desktop) */}

{t('blacklistTitle')}

{blacklist.length === 0 ? (
{t('emptyBlacklist')}
) : ( blacklist.map((item, idx) => (
{item.name}
{item.type}
{item.address && (
{item.address}
)} {item.phone && (
SĐT: {item.phone}
)}
Lý do: {item.reason}
)) )}
{/* Mobile Only: Trusted Members Top Bar */} {trustedUsers.length > 0 && (
🏆 {t('trustedMembers')}
{trustedUsers.slice(0, 5).map((u, idx) => (
#{idx + 1} {u.name} ★ {u.averageScore}
))}
)} {/* Input chọn file ẩn để chụp/chọn ảnh */} {/* Camera input - capture="environment" for rear camera */} {/* Gallery input - no capture attribute for file picker */} {/* Unified Bottom Container: Gallery thumbnails on top, action buttons below */}
{/* Community Gallery Previews */} {publicPhotos.length > 0 && (
{t('momentsTitle')} ({publicPhotos.length})
{publicPhotos.slice(0, 8).map((photo, index) => ( ))}
)} {/* Buttons */}
{/* Login Modal Component */} setIsLoginModalOpen(false)} onSwitchToSignup={onGoToSignup} onLoginSuccess={(loggedInUser) => { setIsLoginModalOpen(false); if (onLoginSuccess) { onLoginSuccess(loggedInUser); } }} /> {/* Report Business Modal */} setIsReportModalOpen(false)} /> {/* Tag Select Modal */} { setIsTagsModalOpen(false); setPendingPhotoFile(null); setPendingPhotoLocation(null); if (photoPreviewUrl) { URL.revokeObjectURL(photoPreviewUrl); setPhotoPreviewUrl(''); } }} onConfirm={handleConfirmTags} photoUrl={photoPreviewUrl} /> {/* Photo Source Selection Modal */} {isPhotoSourceModalOpen && (
{/* Header */}

Chụp hoặc tải ảnh

{/* Content */}
{/* Camera Button */} {/* Gallery Button */}
)} {/* Profile Settings Modal */} setIsProfileSettingsOpen(false)} user={user} onSaveSuccess={onLoginSuccess} /> {/* My Tours Modal */} setIsMyToursOpen(false)} user={user} onViewTour={(tourId) => { if (onGoToMap) { localStorage.setItem('viewTourOnLand', tourId); onGoToMap(); } }} onOpenNavigation={onOpenNavigation} /> {/* My Photos Modal */} setIsMyPhotosOpen(false)} user={user} /> {/* Live Chat Modal */} setIsChatOpen(false)} user={user} defaultChatUserId={chatTargetUserId} /> {/* Friends Manager Modal */} setIsFriendsOpen(false)} user={user} onOpenChatWithUser={(userId) => { setChatTargetUserId(userId); setIsChatOpen(true); }} />
); };