fix: lỗi hiển thị ở frontend

This commit is contained in:
2026-06-21 21:53:14 +07:00
parent 403c169ddd
commit b1a539235b
40 changed files with 5094 additions and 668 deletions
+229 -126
View File
@@ -1,10 +1,12 @@
import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-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;
@@ -14,33 +16,7 @@ interface LandingPageProps {
isInitialSetup?: boolean;
}
// Component Animation cho người lữ hành
const TravelerAnimation = ({ onClick }: { onClick?: () => void }) => {
return <button
onClick={onClick}
className="relative w-48 h-48 flex items-center justify-center transition-transform active:scale-95 focus:outline-none"
style={{
animation: 'gentle-shake 5s ease-in-out infinite',
}}
aria-label="Khám phá bản đồ"
>
{/* Giảm kích thước icon xuống 75% (w-24 -> w-18, md:w-28 -> md:w-21) */}
<Camera className="w-18 h-18 md:w-21 md:h-21 text-white/80 drop-shadow-2xl" strokeWidth={1.5} />
{/* Keyframes cho animation rung lắc */}
<style>{`
@keyframes gentle-shake {
0%, 100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(2deg) scale(1.05);
}
}
`}</style>
</button>;
};
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
@@ -49,6 +25,8 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
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('');
@@ -56,6 +34,39 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
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;
@@ -105,9 +116,22 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
}
};
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(() => {
@@ -125,8 +149,10 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
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(file);
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;
@@ -228,43 +254,67 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
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 */}
<div className="absolute inset-0 z-0 bg-gray-950 overflow-hidden">
{/* Slot 1 */}
{bg1 && (
<img
key={bg1}
src={bg1}
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade1 ? 0.8 : 0,
}}
alt="Travel Background 1"
/>
)}
{/* Slot 2 */}
{bg2 && (
<img
key={bg2}
src={bg2}
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade2 ? 0.8 : 0,
}}
alt="Travel Background 2"
/>
)}
{/* 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: translateX(0) translateZ(0);
transform: scale(1.08) translate(0, 0) translateZ(0);
}
100% {
transform: translateX(-16.667%) translateZ(0);
transform: scale(1.16) translate(-1%, 0.5%) translateZ(0);
}
}
.animate-panning {
@@ -274,18 +324,18 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
`}</style>
{/* Top Bar - Thanh điều hướng trên cùng */}
<div className="absolute top-0 left-0 right-0 z-20 p-4 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-2 text-white drop-shadow-lg">
<Compass className="w-8 h-8" />
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">YoTrip</span>
<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">
<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-3 py-1.5 text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer"
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>
@@ -296,7 +346,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
<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-3 py-1.5 text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer"
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>
@@ -304,11 +354,19 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
</select>
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-2 bg-white/15 backdrop-blur-md text-white font-bold py-2 px-4 rounded-full border border-white/25 hover:bg-white/25 transition-all shadow-md active:scale-95"
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"
>
<LogIn className="w-4 h-4" />
<span className="text-sm">{t('login')}</span>
<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>
@@ -349,6 +407,38 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
</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">
@@ -367,12 +457,6 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
</div>
)}
{/* Body: Animation người lữ hành */}
<div className="absolute inset-0 flex items-center justify-center z-10" >
{/* Kích hoạt click vào file input để chọn hoặc chụp ảnh */}
<TravelerAnimation onClick={() => fileInputRef.current?.click()} />
</div>
{/* Input chọn file ẩn để chụp/chọn ảnh */}
<input
type="file"
@@ -383,59 +467,72 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
className="hidden"
/>
{/* Community Gallery Previews */}
{publicPhotos.length > 0 && (
<div className="absolute bottom-[calc(7.5rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
{t('momentsTitle')} ({publicPhotos.length})
</span>
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 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" className="w-full h-full object-cover" />
{/* Border Overlay absolute to prevent border clipping or corner overlap */}
<div className={`absolute inset-0 rounded-xl border-2 pointer-events-none transition-colors ${
currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
}`} />
</button>
))}
{/* 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>
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
)}
{/* 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>
)}
{/* Bottom Bar - Nút hành động chính */}
<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 sm:flex-row gap-3 items-center justify-center max-w-lg">
<button
onClick={onGoToMap}
className="w-full flex items-center justify-center gap-3 bg-emerald-600/90 text-white font-bold py-3.5 px-6 rounded-2xl transition-all shadow-2xl border border-emerald-500/30 hover:bg-emerald-500 hover:scale-[1.02] active:scale-95 text-sm"
>
<MapIcon className="w-5 h-5" />
{t('exploreToursBtn')}
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-3.5 px-6 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 hover:scale-[1.02] active:scale-95 text-sm"
>
<Camera className="w-5 h-5" />
{t('quickCamera')}
</button>
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
{/* Login Modal Component */}
@@ -445,6 +542,12 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
onSwitchToSignup={onGoToSignup}
onLoginSuccess={onLoginSuccess}
/>
{/* Report Business Modal */}
<ReportBusinessModal
isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)}
/>
</div>
);
};