fix: gộp các nút thành viên và bỏ MemberDashboard

This commit is contained in:
2026-06-27 16:53:19 +07:00
parent 400a3d098d
commit 4ee46371fa
14 changed files with 2638 additions and 497 deletions
+195 -153
View File
@@ -1,12 +1,17 @@
import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert, Image as ImageIcon, X } from 'lucide-react';
import { Compass, Map as MapIcon, Camera, ShieldAlert, Image as ImageIcon, X } from 'lucide-react';
import { LoginModal } from '../components/LoginModal';
import { ReportBusinessModal } from '../components/ReportBusinessModal';
import { TagSelectModal } from '../components/TagSelectModal';
import { MapProfileDropdown } from '../components/MapProfileDropdown';
import { ProfileSettingsModal } from '../components/ProfileSettingsModal';
import { MyToursModal } from '../components/modals/MyToursModal';
import { PhotoGalleryModal as MyPhotosModal } from '../components/modals/PhotoGalleryModal';
import { LiveChatModal } from '../components/modals/LiveChatModal';
import { FriendsManagerModal } from '../components/modals/FriendsManagerModal';
import { 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 {
@@ -15,10 +20,27 @@ interface LandingPageProps {
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 }) => {
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);
@@ -29,8 +51,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
const [pendingPhotoLocation, setPendingPhotoLocation] = useState<GeolocationPosition | null>(null);
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
const notify = useNotification();
const { t, lang, changeLanguage } = useTranslation();
const { theme, changeTheme } = useTheme();
const { t } = useTranslation();
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
@@ -112,7 +133,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
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) {
@@ -121,7 +142,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
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) {
@@ -130,7 +151,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
document.head.appendChild(ogTitle);
}
ogTitle.setAttribute('content', photoTitle);
// Update og:description
let ogDescription = document.querySelector('meta[property="og:description"]');
if (!ogDescription) {
@@ -139,7 +160,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
document.head.appendChild(ogDescription);
}
ogDescription.setAttribute('content', photoDescription);
// Update twitter:image
let twitterImage = document.querySelector('meta[name="twitter:image"]');
if (!twitterImage) {
@@ -168,9 +189,9 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
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);
@@ -201,11 +222,11 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
// Lưu file và location vào state pending, hiển thị modal tags
setPendingPhotoFile(processedFile);
setPendingPhotoLocation(location);
// Tạo preview URL cho ảnh
const previewUrl = URL.createObjectURL(processedFile);
setPhotoPreviewUrl(previewUrl);
setIsTagsModalOpen(true);
} catch (error: any) {
notify({ title: 'Lỗi', message: error.message, type: 'error' });
@@ -222,24 +243,30 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try {
// 1. Xóa token cũ nếu có đ đảm bảo guest không bị nhầm lẫn
localStorage.removeItem('token');
localStorage.removeItem('user');
// 2. 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));
// 1. Kiểm tra xem người dùng đã đăng nhập chưa
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
const isRealUser = token && !guestToken;
let uploadToken = token;
// 2. Nếu là khách, tạo tài khoản khách và lấy token
if (!isRealUser) {
let currentGuestToken = guestToken;
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
if (!currentGuestToken) {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.');
const guestData = await guestRes.json();
currentGuestToken = guestData.access_token;
guestUser = guestData.user;
localStorage.setItem('guest_token', currentGuestToken!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
}
uploadToken = currentGuestToken;
}
// 3. Tải ảnh lên
const formData = new FormData();
formData.append('images', pendingPhotoFile);
@@ -251,31 +278,29 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
if (selectedTags.length > 0) {
formData.append('tags', JSON.stringify(selectedTags));
}
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
headers: { 'Authorization': `Bearer ${uploadToken!}` },
body: formData,
});
if (uploadRes.status === 401) {
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');
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!);
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 ${guestToken!}` },
headers: { 'Authorization': `Bearer ${newGuestToken!}` },
body: formData,
});
}
@@ -284,7 +309,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
const errorData = await uploadRes.json();
throw new Error(errorData.message || 'Tải ảnh thất bại.');
}
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
// Xóa pendingInviteToken và parameter token khỏi URL để tránh tự động chuyển sang JoinTourPage
@@ -317,38 +342,36 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
};
return (
return (
<div className="h-dvh w-full overflow-hidden font-sans bg-[var(--background)] relative">
{/* 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
<img
key={bg1}
src={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"
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
<img
key={bg2}
src={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"
className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
alt="Travel Background 2"
/>
)}
</div>
@@ -407,11 +430,10 @@ return (
<button
key={idx}
onClick={() => setCurrentBgIndex(idx)}
className={`w-2 h-2 rounded-full transition-all ${
currentBgIndex === idx
? 'bg-emerald-500 w-6'
className={`w-2 h-2 rounded-full transition-all ${currentBgIndex === idx
? 'bg-emerald-500 w-6'
: 'bg-white/40 hover:bg-white/60'
}`}
}`}
/>
))}
</div>
@@ -423,58 +445,34 @@ return (
<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>
{/* Android APK Download Button */}
<a
href={`${import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn'}/downloads/yotrip-latest.apk`}
download="yotrip.apk"
className="flex items-center justify-center gap-1.5 bg-green-600/80 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-4 rounded-full border border-green-500/30 hover:bg-green-500 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer shrink-0"
title="Tải ứng dụng Android (.APK)"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span className="hidden sm:inline">Tải bản Android (.APK)</span>
</a>
<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 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>
@@ -515,7 +513,7 @@ return (
</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">
<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>
@@ -565,32 +563,32 @@ return (
)}
{/* 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}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
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 */}
@@ -606,25 +604,22 @@ return (
<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'
}`}
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"
<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' : ''
}`}
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'
}`} />
<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>
@@ -633,7 +628,7 @@ return (
{/* Buttons */}
<div className="w-full flex gap-3 items-center justify-center">
<button
<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"
>
@@ -641,7 +636,7 @@ return (
<span>{t('shortExplore') || 'Khám phá'}</span>
</button>
<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"
>
@@ -649,7 +644,7 @@ return (
<span>{t('shortCamera') || 'Chụp ảnh'}</span>
</button>
</div>
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
@@ -662,9 +657,9 @@ return (
</div>
{/* Login Modal Component */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
onSwitchToSignup={onGoToSignup}
onLoginSuccess={onLoginSuccess}
/>
@@ -745,6 +740,53 @@ return (
</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>
);
};