874 lines
36 KiB
TypeScript
874 lines
36 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
||
import { Compass, Map as MapIcon, Camera as CameraIcon, 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 { processAndResizeImage } from '../utils/imageProcessor';
|
||
import { getDeviceLocation } from '../utils/geolocation';
|
||
import { Capacitor } from '@capacitor/core';
|
||
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
|
||
|
||
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;
|
||
}
|
||
|
||
export const LandingPage: React.FC<LandingPageProps> = ({
|
||
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<string | null>(null);
|
||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
|
||
const [isPhotoSourceModalOpen, setIsPhotoSourceModalOpen] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||
const [pendingPhotoFile, setPendingPhotoFile] = useState<File | null>(null);
|
||
const [pendingPhotoLocation, setPendingPhotoLocation] = useState<{ latitude: number; longitude: number } | null>(null);
|
||
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
|
||
const notify = useNotification();
|
||
const { t } = useTranslation();
|
||
|
||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||
const [blacklist, setBlacklist] = useState<any[]>([]);
|
||
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 processAndUploadFile = async (file: File) => {
|
||
try {
|
||
// Process image: Reads EXIF data and forces 2K resizing on device memory
|
||
const { file: processedFile, latitude: exifLat, longitude: exifLng } = await processAndResizeImage(file);
|
||
// 0. Kiểm duyệt ảnh
|
||
const moderationResult = await processImageModeration(processedFile);
|
||
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 finalProcessedFile = moderationResult.file;
|
||
|
||
// Determine image upload coordinates based on priority checklist:
|
||
let finalLat: number | null = exifLat;
|
||
let finalLng: number | null = exifLng;
|
||
|
||
if (finalLat !== null && finalLng !== null) {
|
||
console.log('[Upload Location] Priority 1: EXIF data coordinates found:', finalLat, finalLng);
|
||
}
|
||
|
||
// 2. Get current mobile/device GPS position of the user
|
||
if (finalLat === null || finalLng === null) {
|
||
try {
|
||
const deviceLoc = await getDeviceLocation();
|
||
if (deviceLoc) {
|
||
finalLat = deviceLoc.latitude;
|
||
finalLng = deviceLoc.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(finalProcessedFile);
|
||
setPendingPhotoLocation({ latitude: finalLat, longitude: finalLng });
|
||
|
||
// Tạo preview URL cho ảnh
|
||
const previewUrl = URL.createObjectURL(finalProcessedFile);
|
||
setPhotoPreviewUrl(previewUrl);
|
||
|
||
setIsTagsModalOpen(true);
|
||
} catch (error: any) {
|
||
notify({ title: 'Lỗi', message: error.message, type: 'error' });
|
||
}
|
||
};
|
||
|
||
const handleNativePhotoPick = async (source: CameraSource) => {
|
||
try {
|
||
const image = await Camera.getPhoto({
|
||
quality: 90,
|
||
allowEditing: false,
|
||
resultType: CameraResultType.Uri,
|
||
source: source,
|
||
saveToGallery: source === CameraSource.Camera // Tự động lưu ảnh gốc vào thư viện nếu chụp bằng Camera
|
||
});
|
||
|
||
if (image && image.webPath) {
|
||
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||
|
||
// Convert Capacitor webPath resource back to standard File instance
|
||
const response = await fetch(image.webPath);
|
||
const blob = await response.blob();
|
||
const originalName = `photo-${Date.now()}.${image.format}`;
|
||
const file = new File([blob], originalName, { type: `image/${image.format}` });
|
||
|
||
// Process this file using our standard handler
|
||
await processAndUploadFile(file);
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Lỗi chọn ảnh native:', error);
|
||
if (error?.message !== 'User cancelled photos app' && error?.message !== 'User cancelled camera') {
|
||
notify({
|
||
title: 'Lỗi',
|
||
message: 'Không thể truy cập máy ảnh hoặc thư viện ảnh.',
|
||
type: 'error'
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
await processAndUploadFile(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();
|
||
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));
|
||
}
|
||
formData.append('images', pendingPhotoFile);
|
||
|
||
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.');
|
||
}
|
||
|
||
// 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 (
|
||
<div className="h-dvh w-full flex flex-col overflow-hidden font-sans bg-[var(--background)]">
|
||
<AppDownloadBanner />
|
||
|
||
<div className="flex-1 w-full relative min-h-0">
|
||
{/* Background Image with Horizontal Panning */}
|
||
<div className="absolute inset-0 z-0">
|
||
{/* Active image for panning */}
|
||
<div className={`absolute inset-0 image-pan-container ${activeSlot === 1 && fade1 ? 'block' : 'hidden'}`}>
|
||
{bg1 && (
|
||
<img
|
||
key={bg1}
|
||
src={bg1}
|
||
draggable="false"
|
||
onContextMenu={(e) => { 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"
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className={`absolute inset-0 image-pan-container ${activeSlot === 2 && fade2 ? 'block' : 'hidden'}`}>
|
||
{bg2 && (
|
||
<img
|
||
key={bg2}
|
||
src={bg2}
|
||
draggable="false"
|
||
onContextMenu={(e) => { 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"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Style for horizontal panning */}
|
||
<style>{`
|
||
.image-pan-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
scroll-behavior: smooth;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.image-pan-container::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
.image-pan-container {
|
||
-ms-overflow-style: none;
|
||
scrollbar-width: none;
|
||
}
|
||
.image-pan-element {
|
||
width: auto;
|
||
max-width: none !important;
|
||
height: 100%;
|
||
display: block;
|
||
min-width: 100%;
|
||
}
|
||
`}</style>
|
||
|
||
{/* Swipe Indicator Arrows - for image switching */}
|
||
{publicPhotos.length > 1 && (
|
||
<>
|
||
<button
|
||
onClick={() => setCurrentBgIndex((prev) => (prev - 1 + publicPhotos.length) % publicPhotos.length)}
|
||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-2 bg-black/30 hover:bg-black/50 backdrop-blur-md rounded-full text-white transition-all md:hidden"
|
||
aria-label="Previous photo"
|
||
>
|
||
<span className="text-xl">‹</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length)}
|
||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 bg-black/30 hover:bg-black/50 backdrop-blur-md rounded-full text-white transition-all md:hidden"
|
||
aria-label="Next photo"
|
||
>
|
||
<span className="text-xl">›</span>
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* Photo Progress Dots */}
|
||
{publicPhotos.length > 1 && (
|
||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 z-10 flex gap-1.5">
|
||
{publicPhotos.slice(0, 20).map((_, idx) => (
|
||
<button
|
||
key={idx}
|
||
onClick={() => setCurrentBgIndex(idx)}
|
||
className={`w-2 h-2 rounded-full transition-all ${currentBgIndex === idx
|
||
? 'bg-emerald-500 w-6'
|
||
: 'bg-white/40 hover:bg-white/60'
|
||
}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Top Bar - Thanh điều hướng trên cùng */}
|
||
<div className="absolute top-0 left-0 right-0 z-20 p-3 sm:p-4 pt-[calc(0.75rem+env(safe-area-inset-top,0px))] sm:pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center bg-gradient-to-b from-slate-950/40 to-transparent">
|
||
<div className="flex items-center gap-1.5 sm:gap-2 text-white drop-shadow-lg">
|
||
<Compass className="w-7 h-7 sm:w-8 sm:h-8" />
|
||
<span className="text-lg sm:text-xl font-black tracking-tighter uppercase hidden sm:block">YoTrip</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 pointer-events-auto">
|
||
{user && !localStorage.getItem('guest_token') && (
|
||
<button
|
||
onClick={() => {
|
||
setChatTargetUserId(null);
|
||
setIsChatOpen(true);
|
||
}}
|
||
className="w-11 h-11 bg-slate-900 border border-slate-800 rounded-full flex items-center justify-center shadow-xl hover:bg-slate-805 text-slate-300 hover:text-white transition-all active:scale-95 cursor-pointer shrink-0 relative group"
|
||
title="Trò chuyện trực tiếp"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 group-hover:scale-105 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||
</svg>
|
||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-blue-500 rounded-full" />
|
||
</button>
|
||
)}
|
||
<MapProfileDropdown
|
||
user={user}
|
||
onLogout={onLogout}
|
||
onOpenSettings={() => setIsProfileSettingsOpen(true)}
|
||
onOpenCreateTour={() => onGoToMap?.()}
|
||
onOpenReport={() => setIsReportModalOpen(true)}
|
||
onOpenLogin={() => setIsLoginModalOpen(true)}
|
||
onOpenMyPhotos={() => setIsMyPhotosOpen(true)}
|
||
onOpenMyTours={() => setIsMyToursOpen(true)}
|
||
onOpenFriends={() => setIsFriendsOpen(true)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Floating Trusted Leaderboard Panel (Left Side on Desktop) */}
|
||
<div className="absolute left-6 top-24 bottom-36 z-20 hidden md:flex flex-col w-72 bg-slate-900/60 backdrop-blur-md border border-white/10 rounded-[32px] p-5 text-white overflow-hidden shadow-2xl animate-in slide-in-from-left duration-500">
|
||
<h3 className="text-base font-black flex items-center gap-2 mb-3 border-b border-white/10 pb-2">
|
||
🏆 {t('trustedMembers')}
|
||
</h3>
|
||
<div className="flex-1 overflow-y-auto space-y-3 pr-1 no-scrollbar">
|
||
{trustedUsers.length === 0 ? (
|
||
<div className="text-xs text-white/50 italic py-4">Chưa có đánh giá nào.</div>
|
||
) : (
|
||
trustedUsers.map((u, idx) => (
|
||
<div key={idx} className="flex items-center gap-3 bg-white/5 hover:bg-white/10 p-2.5 rounded-2xl border border-white/5 transition-all">
|
||
<div className="relative">
|
||
<div className="w-10 h-10 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center font-bold overflow-hidden">
|
||
{u.avatar ? (
|
||
<img src={u.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||
) : (
|
||
<span>{u.name.charAt(0)}</span>
|
||
)}
|
||
</div>
|
||
<span className="absolute -top-1 -left-1 bg-amber-500 text-[10px] text-white font-black px-1.5 py-0.5 rounded-full border border-slate-950">
|
||
{idx + 1}
|
||
</span>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs font-bold truncate">{u.name}</div>
|
||
<div className="flex items-center gap-1.5 mt-0.5">
|
||
<span className="text-[10px] text-amber-400 font-bold">★ {u.averageScore}</span>
|
||
<span className="text-[9px] text-white/40 font-semibold">({u.ratingCount})</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Floating Blacklist Panel (Right Side on Desktop) */}
|
||
<div className="absolute right-6 top-24 bottom-36 z-10 hidden md:flex flex-col w-72 bg-slate-900/60 backdrop-blur-md border border-white/10 rounded-[32px] p-5 text-white overflow-hidden shadow-2xl animate-in slide-in-from-right duration-500 animate-out duration-300">
|
||
<h3 className="text-base font-black flex items-center gap-2 mb-3 border-b border-white/10 pb-2 text-red-400">
|
||
<ShieldAlert className="w-5 h-5 text-red-500" /> {t('blacklistTitle')}
|
||
</h3>
|
||
<div className="flex-1 overflow-y-auto space-y-3 pr-1 no-scrollbar">
|
||
{blacklist.length === 0 ? (
|
||
<div className="text-xs text-white/50 italic py-4 text-center">{t('emptyBlacklist')}</div>
|
||
) : (
|
||
blacklist.map((item, idx) => (
|
||
<div key={idx} className="flex flex-col gap-1.5 bg-white/5 hover:bg-white/10 p-3 rounded-2xl border border-white/5 transition-all text-left">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="text-xs font-bold text-red-300 truncate flex-1">{item.name}</div>
|
||
<span className="text-[8px] bg-red-950/80 text-red-400 border border-red-900/50 px-1.5 py-0.5 rounded font-black shrink-0">
|
||
{item.type}
|
||
</span>
|
||
</div>
|
||
{item.address && (
|
||
<div className="text-[10px] text-white/60 truncate">{item.address}</div>
|
||
)}
|
||
{item.phone && (
|
||
<div className="text-[10px] text-white/40">SĐT: {item.phone}</div>
|
||
)}
|
||
<div className="text-[10px] text-red-400/90 italic bg-red-950/30 p-1.5 rounded-lg border border-red-950/40">
|
||
Lý do: {item.reason}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile Only: Trusted Members Top Bar */}
|
||
{trustedUsers.length > 0 && (
|
||
<div className="absolute top-24 left-4 right-4 z-20 md:hidden flex flex-col gap-1 bg-slate-950/45 backdrop-blur-sm p-2 rounded-2xl border border-white/5">
|
||
<span className="text-[9px] font-black uppercase tracking-wider text-white/70 px-1">
|
||
🏆 {t('trustedMembers')}
|
||
</span>
|
||
<div className="flex gap-2 overflow-x-auto no-scrollbar py-0.5">
|
||
{trustedUsers.slice(0, 5).map((u, idx) => (
|
||
<div key={idx} className="flex items-center gap-1.5 bg-slate-950/60 border border-white/10 px-2.5 py-1 rounded-full shrink-0">
|
||
<span className="text-[9px] font-bold text-amber-400">#{idx + 1}</span>
|
||
<span className="text-[10px] font-bold text-white truncate max-w-[70px]">{u.name}</span>
|
||
<span className="text-[9px] text-amber-400 font-bold">★ {u.averageScore}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Input chọn file ẩn để chụp/chọn ảnh */}
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
onChange={handleFileChange}
|
||
accept="image/*"
|
||
capture="environment"
|
||
className="hidden"
|
||
/>
|
||
|
||
{/* Camera input - capture="environment" for rear camera */}
|
||
<input
|
||
type="file"
|
||
ref={cameraInputRef}
|
||
onChange={handleFileChange}
|
||
accept="image/*"
|
||
capture="environment"
|
||
className="hidden"
|
||
/>
|
||
|
||
{/* Gallery input - no capture attribute for file picker */}
|
||
<input
|
||
type="file"
|
||
ref={galleryInputRef}
|
||
onChange={handleFileChange}
|
||
accept="image/*"
|
||
className="hidden"
|
||
/>
|
||
|
||
{/* Unified Bottom Container: Gallery thumbnails on top, action buttons below */}
|
||
<div className="absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4 flex flex-col items-center gap-4 max-w-md">
|
||
{/* Community Gallery Previews */}
|
||
{publicPhotos.length > 0 && (
|
||
<div className="w-full flex flex-col items-center gap-2">
|
||
<span className="text-[10px] font-black uppercase tracking-wider text-white/75 drop-shadow-md">
|
||
{t('momentsTitle')} ({publicPhotos.length})
|
||
</span>
|
||
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-1 px-4 justify-center">
|
||
{publicPhotos.slice(0, 8).map((photo, index) => (
|
||
<button
|
||
key={photo.id}
|
||
onClick={() => setCurrentBgIndex(index)}
|
||
className={`relative w-14 h-14 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 shadow-inner bg-slate-800 ${currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
|
||
}`}
|
||
>
|
||
<img
|
||
src={photo.imageUrl}
|
||
alt="Community thumbnail"
|
||
draggable="false"
|
||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||
className={`w-full h-full object-cover ${!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||
}`}
|
||
/>
|
||
{/* Border Overlay absolute to prevent border clipping or corner overlap */}
|
||
{/* Inset by 1.5px so it does not get clipped by parent overflow-hidden border */}
|
||
<div className={`absolute inset-[1.5px] rounded-[10px] border-2 pointer-events-none transition-colors ${currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
|
||
}`} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Buttons */}
|
||
<div className="w-full flex gap-3 items-center justify-center">
|
||
<button
|
||
onClick={onGoToMap}
|
||
className="flex-1 flex items-center justify-center gap-2 bg-emerald-600/90 hover:bg-emerald-500 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-emerald-500/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
|
||
>
|
||
<MapIcon className="w-4.5 h-4.5" />
|
||
<span>{t('shortExplore') || 'Khám phá'}</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setIsPhotoSourceModalOpen(true)}
|
||
className="flex-1 flex items-center justify-center gap-2 bg-white/20 backdrop-blur-lg hover:bg-white/30 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-white/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
|
||
>
|
||
<CameraIcon className="w-4.5 h-4.5" />
|
||
<span>{t('shortCamera') || 'Chụp ảnh'}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<style>{`
|
||
.no-scrollbar::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
.no-scrollbar {
|
||
-ms-overflow-style: none;
|
||
scrollbar-width: none;
|
||
}
|
||
`}</style>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Login Modal Component */}
|
||
<LoginModal
|
||
isOpen={isLoginModalOpen}
|
||
onClose={() => setIsLoginModalOpen(false)}
|
||
onSwitchToSignup={onGoToSignup}
|
||
onLoginSuccess={(loggedInUser) => {
|
||
setIsLoginModalOpen(false);
|
||
if (onLoginSuccess) {
|
||
onLoginSuccess(loggedInUser);
|
||
}
|
||
}}
|
||
/>
|
||
|
||
{/* Report Business Modal */}
|
||
<ReportBusinessModal
|
||
isOpen={isReportModalOpen}
|
||
onClose={() => setIsReportModalOpen(false)}
|
||
/>
|
||
|
||
{/* Tag Select Modal */}
|
||
<TagSelectModal
|
||
isOpen={isTagsModalOpen}
|
||
onClose={() => {
|
||
setIsTagsModalOpen(false);
|
||
setPendingPhotoFile(null);
|
||
setPendingPhotoLocation(null);
|
||
if (photoPreviewUrl) {
|
||
URL.revokeObjectURL(photoPreviewUrl);
|
||
setPhotoPreviewUrl('');
|
||
}
|
||
}}
|
||
onConfirm={handleConfirmTags}
|
||
photoUrl={photoPreviewUrl}
|
||
/>
|
||
|
||
{/* Photo Source Selection Modal */}
|
||
{isPhotoSourceModalOpen && (
|
||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||
<div className="bg-[var(--surface)] rounded-3xl shadow-2xl max-w-sm w-full overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||
{/* Header */}
|
||
<div className="bg-gradient-to-r from-blue-600 to-blue-500 px-6 py-6 flex items-center justify-between">
|
||
<h2 className="text-xl font-bold text-white">Chụp hoặc tải ảnh</h2>
|
||
<button
|
||
onClick={() => setIsPhotoSourceModalOpen(false)}
|
||
className="p-1.5 hover:bg-white/20 rounded-full transition-colors"
|
||
>
|
||
<X className="w-5 h-5 text-white" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="p-6 space-y-3">
|
||
{/* Camera Button */}
|
||
<button
|
||
onClick={() => {
|
||
setIsPhotoSourceModalOpen(false);
|
||
if (Capacitor.isNativePlatform()) {
|
||
handleNativePhotoPick(CameraSource.Camera);
|
||
} else {
|
||
cameraInputRef.current?.click();
|
||
}
|
||
}}
|
||
className="w-full flex items-center gap-4 p-4 bg-[var(--background)] hover:bg-[var(--background-alt)] border border-[var(--border)] rounded-2xl transition-all active:scale-95"
|
||
>
|
||
<div className="flex-shrink-0 p-3 bg-blue-100 rounded-full">
|
||
<CameraIcon className="w-6 h-6 text-blue-600" />
|
||
</div>
|
||
<div className="flex-1 text-left">
|
||
<div className="font-bold text-[var(--text-primary)]">Chụp ảnh bằng camera</div>
|
||
<div className="text-sm text-[var(--text-muted)]">Dùng camera thiết bị của bạn</div>
|
||
</div>
|
||
</button>
|
||
|
||
{/* Gallery Button */}
|
||
<button
|
||
onClick={() => {
|
||
setIsPhotoSourceModalOpen(false);
|
||
if (Capacitor.isNativePlatform()) {
|
||
handleNativePhotoPick(CameraSource.Photos);
|
||
} else {
|
||
galleryInputRef.current?.click();
|
||
}
|
||
}}
|
||
className="w-full flex items-center gap-4 p-4 bg-[var(--background)] hover:bg-[var(--background-alt)] border border-[var(--border)] rounded-2xl transition-all active:scale-95"
|
||
>
|
||
<div className="flex-shrink-0 p-3 bg-emerald-100 rounded-full">
|
||
<ImageIcon className="w-6 h-6 text-emerald-600" />
|
||
</div>
|
||
<div className="flex-1 text-left">
|
||
<div className="font-bold text-[var(--text-primary)]">Tải ảnh từ thư viện</div>
|
||
<div className="text-sm text-[var(--text-muted)]">Chọn ảnh từ thiết bị của bạn</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Profile Settings Modal */}
|
||
<ProfileSettingsModal
|
||
isOpen={isProfileSettingsOpen}
|
||
onClose={() => setIsProfileSettingsOpen(false)}
|
||
user={user}
|
||
onSaveSuccess={onLoginSuccess}
|
||
/>
|
||
|
||
{/* My Tours Modal */}
|
||
<MyToursModal
|
||
isOpen={isMyToursOpen}
|
||
onClose={() => setIsMyToursOpen(false)}
|
||
user={user}
|
||
onViewTour={(tourId) => {
|
||
if (onGoToMap) {
|
||
localStorage.setItem('viewTourOnLand', tourId);
|
||
onGoToMap();
|
||
}
|
||
}}
|
||
onOpenNavigation={onOpenNavigation}
|
||
/>
|
||
|
||
{/* My Photos Modal */}
|
||
<MyPhotosModal
|
||
isOpen={isMyPhotosOpen}
|
||
onClose={() => setIsMyPhotosOpen(false)}
|
||
user={user}
|
||
/>
|
||
|
||
{/* Live Chat Modal */}
|
||
<LiveChatModal
|
||
isOpen={isChatOpen}
|
||
onClose={() => setIsChatOpen(false)}
|
||
user={user}
|
||
defaultChatUserId={chatTargetUserId}
|
||
/>
|
||
|
||
{/* Friends Manager Modal */}
|
||
<FriendsManagerModal
|
||
isOpen={isFriendsOpen}
|
||
onClose={() => setIsFriendsOpen(false)}
|
||
user={user}
|
||
onOpenChatWithUser={(userId) => {
|
||
setChatTargetUserId(userId);
|
||
setIsChatOpen(true);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}; |