Files
travelplanning/frontend/src/pages/LandingPage.tsx
T

553 lines
24 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert } from 'lucide-react';
import { LoginModal } from '../components/LoginModal';
import { ReportBusinessModal } from '../components/ReportBusinessModal';
import { useNotification } from '@/hooks/useNotification';
import { processImageModeration } from '../hooks/useImageModeration';
import { useTranslation } from '../hooks/useTranslation';
import { useTheme } from '../hooks/useTheme';
import { compressImage } from '../utils/image';
interface LandingPageProps {
onContinue?: () => void;
onGoToSignup?: () => void;
onGoToMap?: () => void;
onLoginSuccess?: (user: any) => void;
isInitialSetup?: boolean;
}
export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
const { t, lang, changeLanguage } = useTranslation();
const { theme, changeTheme } = useTheme();
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
const [blacklist, setBlacklist] = useState<any[]>([]);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
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 [touchOffsetX, setTouchOffsetX] = useState(0);
const touchStartXRef = useRef(0);
const isSwipingRef = useRef(false);
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
const handleTouchStart = (e: React.TouchEvent) => {
touchStartXRef.current = e.touches[0].clientX;
isSwipingRef.current = true;
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isSwipingRef.current) return;
const currentX = e.touches[0].clientX;
const diffX = currentX - touchStartXRef.current;
// Clamp the translation to [-70, 70] to ensure the background never shows white/black gaps
const clampedX = Math.max(-70, Math.min(70, diffX));
setTouchOffsetX(clampedX);
};
const handleTouchEnd = () => {
if (!isSwipingRef.current) return;
isSwipingRef.current = false;
// Threshold lowered to 50px for better swipe responsiveness on mobile screens
if (touchOffsetX > 50 && publicPhotos.length > 0) {
setCurrentBgIndex((prev) => (prev - 1 + publicPhotos.length) % publicPhotos.length);
} else if (touchOffsetX < -50 && publicPhotos.length > 0) {
setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length);
}
setTouchOffsetX(0);
};
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);
}
};
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<HTMLInputElement>) => {
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;
// Lấy tọa độ hiện tại của người dùng với cơ chế chống treo (Promise.race)
const location = await Promise.race([
new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
} else {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
() => resolve(null),
{ timeout: 4000, enableHighAccuracy: true }
);
}
}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
]);
// 1. Tạo tài khoản khách và lấy token
let guestToken = localStorage.getItem('guest_token');
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
if (!guestToken) {
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();
guestToken = guestData.access_token;
guestUser = guestData.user;
localStorage.setItem('guest_token', guestToken!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
}
// 2. Tải ảnh lên
const formData = new FormData();
formData.append('images', processedFile);
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
}
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
body: formData,
});
if (uploadRes.status === 401) {
console.warn('Guest token invalid or expired. Creating a new guest user and retrying...');
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
localStorage.removeItem('token');
localStorage.removeItem('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();
guestToken = guestData.access_token;
guestUser = guestData.user;
localStorage.setItem('guest_token', guestToken!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
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' });
// Cập nhật lại danh sách ảnh lập tức
await fetchPublicPhotos();
setCurrentBgIndex(0);
// Đăng nhập luôn bằng tài khoản khách này để người dùng xem được ảnh của mình trên bản đồ
localStorage.setItem('token', guestToken!);
localStorage.setItem('user', JSON.stringify(guestUser));
if (onLoginSuccess) {
onLoginSuccess(guestUser);
}
} 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 = '';
}
};
return (
<div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative">
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning & Swiping */}
<div
className="absolute inset-0 z-0 bg-gray-950 overflow-hidden"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
className="absolute inset-y-0 left-[-15%] right-[-15%] cursor-grab active:cursor-grabbing"
style={{
transform: `translateX(${touchOffsetX}px)`,
transition: touchOffsetX === 0 ? 'transform 0.3s ease-out' : 'none',
touchAction: 'none'
}}
>
{/* Slot 1 */}
{bg1 && (
<img
key={bg1}
src={bg1}
draggable="false"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
style={{
opacity: fade1 ? 0.8 : 0,
}}
alt="Travel Background 1"
/>
)}
{/* Slot 2 */}
{bg2 && (
<img
key={bg2}
src={bg2}
draggable="false"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
style={{
opacity: fade2 ? 0.8 : 0,
}}
alt="Travel Background 2"
/>
)}
</div>
</div>
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
<style>{`
@keyframes pan-left-to-right {
0% {
transform: scale(1.08) translate(0, 0) translateZ(0);
}
100% {
transform: scale(1.16) translate(-1%, 0.5%) translateZ(0);
}
}
.animate-panning {
animation: pan-left-to-right 13500ms linear forwards;
will-change: transform;
}
`}</style>
{/* 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-1.5 sm:gap-3">
{/* Language Selector */}
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-2 py-1 sm:px-3 sm:py-1.5 text-[11px] sm:text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer max-w-[80px] sm:max-w-none"
>
<option value="vi" className="text-black">Tiếng Việt</option>
<option value="en" className="text-black">English</option>
<option value="zh" className="text-black">中文</option>
</select>
{/* Theme Selector */}
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-2 py-1 sm:px-3 sm:py-1.5 text-[11px] sm:text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer max-w-[80px] sm:max-w-none"
>
<option value="light" className="text-black">{t('themeLight') || 'Sáng'}</option>
<option value="dark" className="text-black">{t('themeDark') || 'Tối'}</option>
<option value="system" className="text-black">{t('themeSystem') || 'Hệ thống'}</option>
</select>
<button
onClick={() => setIsReportModalOpen(true)}
className="flex items-center justify-center gap-1.5 bg-red-600/80 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-3.5 rounded-full border border-red-500/30 hover:bg-red-500 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer"
>
<ShieldAlert className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('reportBusinessBtn')}</span>
</button>
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-1 sm:gap-2 bg-white/15 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-4 rounded-full border border-white/25 hover:bg-white/25 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer"
>
<LogIn className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span>{t('login')}</span>
</button>
</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 đá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-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-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">
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"
/>
{/* 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={() => fileInputRef.current?.click()}
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"
>
<Camera 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>
{/* Login Modal Component */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
onSwitchToSignup={onGoToSignup}
onLoginSuccess={onLoginSuccess}
/>
{/* Report Business Modal */}
<ReportBusinessModal
isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)}
/>
</div>
);
};