553 lines
24 KiB
TypeScript
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 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-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">
|
|
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"
|
|
/>
|
|
|
|
{/* 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>
|
|
);
|
|
}; |