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
+27 -65
View File
@@ -6,7 +6,6 @@ import SignupPage from './pages/SignupPage';
import { MyPhotosPage } from './pages/MyPhotosPage';
import { MyNotePage } from './pages/MyNotePage';
import { JoinTourPage } from './pages/JoinTourPage';
import { MemberDashboard } from './pages/MemberDashboard';
import { AdminDashboard } from './pages/AdminDashboard';
import { ShareJourneyPage } from './pages/ShareJourneyPage';
import { TourNavigationPage } from './pages/TourNavigationPage';
@@ -139,7 +138,7 @@ function App() {
);
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
const [previousPage, setPreviousPage] = useState<'explore' | 'landing'>('explore');
const [navigationPayload, setNavigationPayload] = useState<{ tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string } | null>(null);
useEffect(() => {
@@ -161,7 +160,6 @@ function App() {
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
const storedUser = localStorage.getItem('user');
const storedGuestUser = localStorage.getItem('guest_user');
let loggedInUser = null;
if (token && storedUser) {
@@ -191,7 +189,7 @@ function App() {
if (loggedInUser.isAdmin) {
setCurrentPage('admin');
} else {
setCurrentPage('dashboard');
setCurrentPage('landing');
}
} else {
setCurrentPage('landing');
@@ -210,14 +208,7 @@ function App() {
// Nếu là admin, chuyển đến admin dashboard
setCurrentPage('admin');
} else {
// Only set to dashboard if this is a real user (has token), not a guest
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
if (token && !guestToken) {
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
// Keep on current page after successful login, modal will close
}
};
@@ -228,10 +219,11 @@ function App() {
setCurrentPage('landing');
};
const handleViewTour = (tourId: string, fromPage?: 'explore' | 'dashboard') => {
const handleViewTour = (tourId: string, fromPage?: 'explore') => {
setCurrentTourId(tourId);
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
setPreviousPage(fromPage || (currentPage === 'dashboard' ? 'dashboard' : 'explore'));
setPreviousPage(fromPage || 'explore');
setCurrentPage('tourDetail');
};
@@ -296,30 +288,11 @@ function App() {
};
const handleBackFromExplore = () => {
// Only allow real users (with token, not guest_token)
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
const isRealUser = token && !guestToken;
if (user && isRealUser) {
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
setCurrentPage('landing');
};
const handleGoToDashboard = () => {
// Only allow real users (with token, not guest_token)
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
const isRealUser = token && !guestToken;
if (user && isRealUser) {
setPreviousPage('explore');
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
const handleGoToDashboard = (_tab?: 'tours' | 'connections' | 'photos' | 'chats') => {
// Deprecated dashboard redirect: keeping empty function to satisfy interface prop requirements
};
const handleGoToHome = () => {
@@ -347,32 +320,7 @@ function App() {
return (
<AdminDashboard
user={user}
onNavigate={setCurrentPage}
/>
);
}
if (currentPage === 'dashboard') {
// SECURITY: Prevent any guest from accessing dashboard
const guestToken = localStorage.getItem('guest_token');
if (guestToken) {
console.warn('[App] Guest user attempted to access dashboard - forcing redirect to landing');
setCurrentPage('landing');
return (
<LandingPage
onLoginSuccess={handleLoginSuccess}
onGoToSignup={() => setCurrentPage('signup')}
/>
);
}
return (
<MemberDashboard
user={user}
onLogout={handleLogout}
onExploreTours={() => setCurrentPage('explore')}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onNavigate={(page) => setCurrentPage(page as any)}
/>
);
}
@@ -384,7 +332,10 @@ function App() {
onBack={handleBackFromTourDetail}
isPublicView={isPublicTourView}
onOpenNotes={() => setCurrentPage('notes')}
onOpenNavigationPage={handleOpenNavigationPage}
onOpenNavigationPage={(routeData) => handleOpenNavigationPage({
tourId: currentTourId!,
...routeData
})}
/>
);
}
@@ -413,9 +364,9 @@ function App() {
onLogout={handleLogout}
user={user}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onLoginSuccess={handleLoginSuccess}
onGoToDashboard={handleGoToDashboard}
onOpenNavigation={handleOpenNavigationPage}
/>
);
}
@@ -457,7 +408,18 @@ function App() {
);
}
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
return (
<LandingPage
onContinue={() => setCurrentPage('explore')}
onGoToSignup={() => setCurrentPage('signup')}
onGoToMap={() => setCurrentPage('explore')}
onLoginSuccess={handleLoginSuccess}
user={user}
onLogout={handleLogout}
onGoToDashboard={handleGoToDashboard}
onOpenNavigation={handleOpenNavigationPage}
/>
);
})()}
</NotificationProvider>
</ConfirmProvider>
+57 -40
View File
@@ -1,29 +1,31 @@
import React, { useState } from 'react';
import { LayoutDashboard, Settings, Compass, ShieldAlert, LogOut, LogIn, Globe, Sun, Moon, Image as ImageIcon } from 'lucide-react';
import { Compass, Map, Image as ImageIcon, Settings, ShieldAlert, Users, LogOut, LogIn, Globe, Sun, Moon } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
interface MapProfileDropdownProps {
user: any;
onLogout?: () => void;
onGoToDashboard?: () => void;
onOpenSettings: () => void;
onOpenCreateTour: () => void;
onOpenReport: () => void;
onOpenLogin: () => void;
onOpenMyPhotos?: () => void;
onOpenMyPhotos: () => void;
onOpenMyTours: () => void;
onOpenFriends: () => void;
onOpenAdmin?: () => void;
}
export const MapProfileDropdown: React.FC<MapProfileDropdownProps> = ({
user,
onLogout,
onGoToDashboard,
onOpenSettings,
onOpenCreateTour,
onOpenReport,
onOpenLogin,
onOpenMyPhotos,
onOpenMyTours,
onOpenFriends,
onOpenAdmin,
}) => {
const [isOpen, setIsOpen] = useState(false);
@@ -44,6 +46,11 @@ export const MapProfileDropdown: React.FC<MapProfileDropdownProps> = ({
);
};
const handleItemClick = (callback: () => void) => {
setIsOpen(false);
callback();
};
return (
<div className="relative inline-block text-left select-none pointer-events-auto">
{/* TRIGGER BUTTON */}
@@ -69,17 +76,18 @@ export const MapProfileDropdown: React.FC<MapProfileDropdownProps> = ({
{isOpen && (
<>
<div
className="fixed inset-0 z-40 bg-black/50 sm:bg-transparent"
className="fixed inset-0 z-[999998] bg-black/50 sm:bg-transparent"
onClick={() => setIsOpen(false)}
/>
<div
className="fixed bottom-0 left-0 right-0 sm:absolute sm:bottom-auto sm:top-14 sm:right-0 sm:left-auto z-50 w-full sm:w-64 bg-slate-900 border-t sm:border border-slate-800 rounded-t-3xl sm:rounded-2xl p-3 sm:p-2 shadow-2xl animate-in slide-in-from-bottom sm:slide-in-from-top-2 duration-300 text-xs text-slate-200"
className="fixed bottom-0 left-0 right-0 sm:absolute sm:bottom-auto sm:top-14 sm:right-0 sm:left-auto z-[999999] w-full sm:w-64 bg-slate-900 border-t sm:border border-slate-800 rounded-t-3xl sm:rounded-2xl p-3 sm:p-2 shadow-2xl animate-in slide-in-from-bottom sm:slide-in-from-top-2 duration-300 text-xs text-slate-200"
>
<div className="w-12 h-1 bg-slate-700 rounded-full mx-auto mb-3 sm:hidden" />
{isAuthenticated ? (
<div className="flex flex-col space-y-1">
{/* User info header */}
<div className="px-4 py-2 border-b border-slate-800/60 mb-1 flex items-center gap-3">
<div className="w-8 h-8 rounded-full overflow-hidden shrink-0 border border-slate-700">
{user?.avatar ? (
@@ -94,57 +102,73 @@ export const MapProfileDropdown: React.FC<MapProfileDropdownProps> = ({
</div>
</div>
{/* Extra System Admin option */}
{user?.isAdmin && (
<button
onClick={() => { setIsOpen(false); onOpenAdmin?.(); }}
className="flex items-center gap-3 w-full px-4 py-3 bg-blue-950/20 hover:bg-slate-805 rounded-xl text-left transition-colors font-bold text-blue-400 cursor-pointer"
onClick={() => handleItemClick(onOpenAdmin || (() => {}))}
className="flex items-center gap-3 w-full px-4 py-2.5 bg-blue-950/20 hover:bg-slate-800 rounded-lg text-left transition-colors font-bold text-blue-400 cursor-pointer"
>
<Settings className="w-4 h-4 shrink-0 text-blue-400" /> Quản trị hệ thống
</button>
)}
{/* 1. Tạo tour */}
<button
onClick={() => { setIsOpen(false); onGoToDashboard?.(); }}
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
onClick={() => handleItemClick(onOpenCreateTour)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<LayoutDashboard className="w-4 h-4 text-blue-400 shrink-0" /> Bảng điều khiển
<Compass className="w-4 h-4 text-amber-400 shrink-0" /> Tạo tour
</button>
{/* 2. Hành trình của tôi */}
<button
onClick={() => { setIsOpen(false); onOpenSettings(); }}
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
onClick={() => handleItemClick(onOpenMyTours)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<Settings className="w-4 h-4 text-emerald-400 shrink-0" /> Cài đt cá nhân
</button>
<button
onClick={() => { setIsOpen(false); onOpenCreateTour(); }}
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
>
<Compass className="w-4 h-4 text-amber-400 shrink-0" /> Tạo Tour mới
<Map className="w-4 h-4 text-blue-400 shrink-0" /> Hành trình của tôi
</button>
{/* 3. Thư viện ảnh */}
<button
onClick={() => { setIsOpen(false); onOpenMyPhotos?.(); }}
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
onClick={() => handleItemClick(onOpenMyPhotos)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<ImageIcon className="w-4 h-4 text-sky-400 shrink-0" /> nh của tôi
<ImageIcon className="w-4 h-4 text-emerald-400 shrink-0" /> Thư viện nh
</button>
{/* 4. Cài đặt */}
<button
onClick={() => { setIsOpen(false); onOpenReport(); }}
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
onClick={() => handleItemClick(onOpenSettings)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" /> Báo cáo sai phạm
<Settings className="w-4 h-4 text-slate-400 shrink-0" /> Cài đt
</button>
<div className="h-[1px] bg-slate-855 my-1 mx-2" />
{/* 5. Báo cáo vi phạm */}
<button
onClick={() => { setIsOpen(false); onLogout?.(); }}
className="flex items-center gap-3 w-full px-4 py-3 text-rose-400 hover:bg-slate-800 rounded-xl text-left font-black transition-colors cursor-pointer"
onClick={() => handleItemClick(onOpenReport)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<LogOut className="w-4 h-4 shrink-0" /> Đăng xuất
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" /> Báo cáo vi phạm
</button>
{/* STRICT VISUAL DIVIDER LINE */}
<div className="border-b border-slate-800 my-1 mx-2" />
{/* 6. Danh sách bạn bè */}
<button
onClick={() => handleItemClick(onOpenFriends)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<Users className="w-4 h-4 text-indigo-400 shrink-0" /> Danh sách bạn
</button>
{/* 7. Đăng xuất */}
<button
onClick={() => handleItemClick(onLogout || (() => {}))}
className="flex items-center gap-3 w-full px-4 py-2.5 text-rose-400 hover:bg-slate-800 rounded-lg text-left font-bold transition-colors cursor-pointer"
>
<LogOut className="w-4 h-4 shrink-0 text-rose-500" /> Đăng xuất
</button>
</div>
) : (
@@ -188,17 +212,10 @@ export const MapProfileDropdown: React.FC<MapProfileDropdownProps> = ({
</div>
</div>
<button
onClick={() => { setIsOpen(false); onOpenMyPhotos?.(); }}
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
>
<ImageIcon className="w-4 h-4 text-sky-400 shrink-0" /> nh của tôi
</button>
<div className="h-[1px] bg-slate-850 my-1 mx-2" />
<button
onClick={() => { setIsOpen(false); onOpenLogin(); }}
onClick={() => handleItemClick(onOpenLogin)}
className="flex items-center gap-3 w-full px-4 py-3 text-blue-400 hover:bg-slate-800 rounded-xl text-left font-black transition-colors cursor-pointer"
>
<LogIn className="w-4 h-4 shrink-0" /> Đăng / Đăng nhập
+206
View File
@@ -0,0 +1,206 @@
import React, { useEffect, useState } from 'react';
import { X, Image as ImageIcon, Loader2, Calendar, Download, Eye, MapPin } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
interface MyPhotosModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
}
export const MyPhotosModal: React.FC<MyPhotosModalProps> = ({
isOpen,
onClose,
user,
}) => {
const notify = useNotification();
const [photos, setPhotos] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
useEffect(() => {
if (isOpen && user) {
const loadPhotos = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/v1/users/me/photos', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (res.ok) {
const data = await res.json();
setPhotos(data);
} else {
throw new Error('Không thể tải thư viện ảnh.');
}
} catch (e: any) {
console.error(e);
notify({
title: 'Lỗi',
message: e.message || 'Không thể tải ảnh.',
type: 'error',
});
} finally {
setIsLoading(false);
}
};
loadPhotos();
}
}, [isOpen, user]);
if (!isOpen || !user) return null;
const handleDownload = async (url: string, filename: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error(e);
notify({
title: 'Lỗi tải về',
message: 'Không thể tải trực tiếp ảnh xuống thiết bị.',
type: 'error',
});
}
};
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-3xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-emerald-400" /> Thư viện nh
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content body */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading ? (
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
<Loader2 className="w-8 h-8 text-emerald-400 animate-spin" />
<span className="text-slate-400 font-medium">Đang tải thư viện nh của bạn...</span>
</div>
) : photos.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<ImageIcon className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Thư viện nh trống</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn chưa đăng tải bức nh nào trong các chuyến đi của mình.</p>
</div>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{photos.map((photo) => {
const dateStr = photo.capturedAt ? new Date(photo.capturedAt).toLocaleDateString('vi-VN') : '';
return (
<div
key={photo.id}
className="group relative aspect-square rounded-2xl overflow-hidden bg-slate-950 border border-slate-850 hover:border-slate-700 shadow-lg transition-all flex flex-col"
>
<img
src={photo.imageUrl}
alt="Gallery Asset"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
{/* Hover controls overlay */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex flex-col justify-between p-3.5 transition-opacity duration-250 z-10">
<div className="flex justify-end gap-1.5">
<button
onClick={() => setSelectedPhoto(photo)}
className="p-2 bg-slate-900/85 hover:bg-indigo-650 text-white rounded-xl transition-colors cursor-pointer"
title="Xem phóng to"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleDownload(photo.originalUrl || photo.imageUrl, `yotrip-photo-${photo.id}.jpg`)}
className="p-2 bg-slate-900/85 hover:bg-emerald-650 text-white rounded-xl transition-colors cursor-pointer"
title="Tải về tệp gốc"
>
<Download className="w-4 h-4" />
</button>
</div>
<div className="min-w-0">
{photo.tour?.title && (
<div className="text-[10px] font-bold text-indigo-300 truncate flex items-center gap-1">
<MapPin className="w-3 h-3 shrink-0" />
{photo.tour.title}
</div>
)}
<div className="text-[9px] text-slate-400 mt-0.5 flex items-center gap-1">
<Calendar className="w-3 h-3 shrink-0" />
{dateStr}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Fullscreen Preview overlay */}
{selectedPhoto && (
<div className="fixed inset-0 z-[2000000] bg-black/95 flex flex-col justify-between p-4 pointer-events-auto">
{/* Close trigger top bar */}
<div className="flex justify-between items-center w-full pb-3 border-b border-slate-900">
<div className="text-white font-bold text-xs truncate">
{selectedPhoto.tour?.title || 'Xem ảnh'}
</div>
<button
onClick={() => setSelectedPhoto(null)}
className="p-2 bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white rounded-xl cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Fullscreen Photo view */}
<div className="flex-1 flex items-center justify-center p-4">
<img
src={selectedPhoto.originalUrl || selectedPhoto.imageUrl}
alt="Fullscreen Preview"
className="max-w-full max-h-[75vh] object-contain rounded-xl shadow-2xl"
/>
</div>
{/* Action bottom bar */}
<div className="flex justify-center items-center py-4 border-t border-slate-900">
<button
onClick={() => handleDownload(selectedPhoto.originalUrl || selectedPhoto.imageUrl, `yotrip-photo-${selectedPhoto.id}.jpg`)}
className="flex items-center gap-2 px-5 py-3 bg-emerald-650 hover:bg-emerald-600 font-bold text-white rounded-xl shadow-lg transition-all active:scale-95 cursor-pointer"
>
<Download className="w-4 h-4" /> Tải về tệp gốc (.jpg)
</button>
</div>
</div>
)}
</div>
);
};
+179
View File
@@ -0,0 +1,179 @@
import React, { useEffect, useState } from 'react';
import { X, Calendar, Users, Navigation, Trash2, Loader2, Compass } from 'lucide-react';
import { useTourStore } from '../store/useTourStore';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
interface MyToursModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
onViewTour: (tourId: string) => void;
}
export const MyToursModal: React.FC<MyToursModalProps> = ({
isOpen,
onClose,
user,
onViewTour,
}) => {
const notify = useNotification();
const confirm = useConfirm();
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const deleteTour = useTourStore(state => state.deleteTour);
const [isLoading, setIsLoading] = useState(false);
const [isDeletingId, setIsDeletingId] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
const loadTours = async () => {
setIsLoading(true);
try {
await fetchPublicTours();
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
loadTours();
}
}, [isOpen, fetchPublicTours]);
if (!isOpen || !user) return null;
// Filter tours where user is participant
const myTours = publicTours.filter(tour =>
tour.participants?.some((p: any) => p.userId === user?.id)
);
const handleDelete = async (tourId: string, tourTitle: string) => {
const ok = await confirm({
title: 'Xóa chuyến đi?',
message: `Bạn có chắc chắn muốn xóa chuyến đi "${tourTitle}" không? Hành động này không thể hoàn tác.`,
});
if (!ok) return;
setIsDeletingId(tourId);
try {
await deleteTour(tourId);
notify({
title: 'Thành công',
message: 'Đã xóa chuyến đi thành công.',
type: 'success',
});
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Xóa chuyến đi thất bại.',
type: 'error',
});
} finally {
setIsDeletingId(null);
}
};
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<Compass className="w-5 h-5 text-indigo-400" /> Hành trình của tôi
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content body */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading ? (
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
<span className="text-slate-400 font-medium">Đang tải danh sách chuyến đi...</span>
</div>
) : myTours.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<Compass className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Chưa hành trình nào</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn chưa tham gia chuyến đi nào. Hãy nhấp Tạo Tour mới đ bắt đu hành trình của riêng mình!</p>
</div>
</div>
) : (
<div className="space-y-3.5">
{myTours.map((tour) => {
const isOwner = tour.participants?.some(
(p: any) => p.userId === user?.id && p.role === 'OWNER'
);
const startDateStr = tour.startDate ? new Date(tour.startDate).toLocaleDateString('vi-VN') : '';
const endDateStr = tour.endDate ? new Date(tour.endDate).toLocaleDateString('vi-VN') : '';
return (
<div
key={tour.id}
className="p-4 bg-slate-950/40 border border-slate-850 hover:border-slate-750 rounded-2xl flex flex-col gap-3 transition-all"
>
<div className="flex justify-between items-start gap-4">
<div className="min-w-0">
<h4 className="font-bold text-white text-sm truncate">{tour.title}</h4>
<div className="flex items-center gap-4 text-[10px] text-slate-400 mt-1 font-semibold">
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-slate-500 shrink-0" />
{startDateStr} - {endDateStr}
</span>
<span className="flex items-center gap-1">
<Users className="w-3.5 h-3.5 text-slate-500 shrink-0" />
{tour.participants?.length || 0} thành viên
</span>
</div>
</div>
</div>
<div className="flex justify-end items-center gap-2 border-t border-slate-850 pt-3">
{isOwner && (
<button
onClick={() => handleDelete(tour.id, tour.title)}
disabled={isDeletingId === tour.id}
className="p-2 text-rose-400 hover:bg-rose-500/10 rounded-xl transition-all cursor-pointer shrink-0 disabled:opacity-50"
title="Xóa chuyến đi"
>
{isDeletingId === tour.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
)}
<button
onClick={() => {
onClose();
onViewTour(tour.id);
}}
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-650 hover:bg-indigo-600 font-bold text-white rounded-xl transition-all active:scale-95 cursor-pointer shadow-md shadow-indigo-900/10"
>
<Navigation className="w-3.5 h-3.5" /> Xem hành trình
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,442 @@
import React, { useEffect, useState } from 'react';
import { X, Search, Users, UserPlus, UserCheck, MessageSquare, Trash2, Check, UserX, Loader2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
interface FriendsManagerModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
onOpenChatWithUser?: (userId: string) => void;
}
export const FriendsManagerModal: React.FC<FriendsManagerModalProps> = ({
isOpen,
onClose,
user,
onOpenChatWithUser,
}) => {
const notify = useNotification();
const confirm = useConfirm();
const [activeTab, setActiveTab] = useState<'friends' | 'pending' | 'search'>('friends');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
// Connection Lists
const [connections, setConnections] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const getHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`,
});
// Fetch direct connections list
const fetchConnections = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/v1/connections', { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
setConnections(data || []);
}
} catch (e) {
console.error('[FriendsManagerModal] Error fetching connections:', e);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isOpen && user) {
fetchConnections();
}
}, [isOpen, user]);
// Handle connection search lookup
const handleSearchUsers = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
setIsSearching(true);
try {
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(searchQuery)}`, { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
// Filter out current user from search results
setSearchResults((data || []).filter((u: any) => u.id !== user?.id));
}
} catch (e) {
console.error('[FriendsManagerModal] Search error:', e);
} finally {
setIsSearching(false);
}
};
// Accept/Reject friend request
const handleUpdateStatus = async (connId: string, status: 'ACCEPTED' | 'REJECTED') => {
try {
const res = await fetch(`/api/v1/connections/${connId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ status }),
});
if (res.ok) {
notify({
title: 'Thành công',
message: status === 'ACCEPTED' ? 'Đã chấp nhận kết nối.' : 'Đã từ chối kết nối.',
type: 'success',
});
fetchConnections();
} else {
throw new Error('Cập nhật kết nối thất bại.');
}
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message,
type: 'error',
});
}
};
// Disconnect / Unfriend / Cancel request
const handleDisconnect = async (connId: string, name: string) => {
const ok = await confirm({
title: 'Hủy kết nối?',
message: `Bạn có chắc chắn muốn hủy kết nối với ${name} không?`,
});
if (!ok) return;
try {
const res = await fetch(`/api/v1/connections/${connId}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (res.ok) {
notify({
title: 'Thành công',
message: 'Hủy kết nối thành công.',
type: 'success',
});
fetchConnections();
} else {
throw new Error('Lỗi hủy kết nối.');
}
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message,
type: 'error',
});
}
};
// Send request connection
const handleSendRequest = async (receiverId: string) => {
try {
const res = await fetch('/api/v1/connections', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ receiverId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Gửi lời mời thất bại.');
notify({
title: 'Thành công',
message: 'Đã gửi lời mời kết nối thành công.',
type: 'success',
});
fetchConnections();
// Reset search lists to update button states
setSearchResults(prev => prev.map(u => u.id === receiverId ? { ...u, pendingSent: true } : u));
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message,
type: 'error',
});
}
};
if (!isOpen || !user) return null;
// Filter lists
const activeFriends = connections.filter((c: any) => c.status === 'ACCEPTED');
// Received pending requests
const pendingRequests = connections.filter((c: any) => c.status === 'PENDING' && c.targetUser?.id === user?.id);
const getStatusText = (targetUserId: string) => {
const existing = connections.find(
c => c.targetUser?.id === targetUserId || c.requester?.id === targetUserId
);
if (!existing) return null;
if (existing.status === 'ACCEPTED') return 'FRIEND';
if (existing.status === 'PENDING') {
return existing.requester?.id === user?.id ? 'SENT' : 'RECEIVED';
}
return null;
};
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<Users className="w-5 h-5 text-indigo-400" /> Danh sách bạn
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tab Selection Header */}
<div className="bg-slate-950/40 p-2.5 border-b border-slate-850 flex gap-2 shrink-0">
{[
{ id: 'friends', label: `Bạn bè (${activeFriends.length})` },
{ id: 'pending', label: `Lời mời (${pendingRequests.length})` },
{ id: 'search', label: 'Tìm bạn mới' }
].map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 py-2 rounded-xl text-center font-bold transition-all cursor-pointer ${
isActive
? 'bg-indigo-650 text-white shadow-md'
: 'bg-slate-900/40 hover:bg-slate-850 text-slate-400'
}`}
>
{tab.label}
</button>
);
})}
</div>
{/* Content body */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading && activeTab !== 'search' ? (
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
<span className="text-slate-400 font-medium">Đang tải dữ liệu...</span>
</div>
) : activeTab === 'friends' ? (
activeFriends.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<Users className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Chưa bạn kết nối</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Chọn mục "Tìm bạn mới" đ tìm kiếm gửi lời mời kết bạn.</p>
</div>
</div>
) : (
<div className="space-y-2.5">
{activeFriends.map((conn) => {
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
return (
<div
key={conn.id}
className="p-3 bg-slate-950/40 border border-slate-850 rounded-xl flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-850 overflow-hidden flex items-center justify-center font-bold border border-slate-800 shrink-0">
{friend.avatar ? (
<img src={friend.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{friend.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="min-w-0">
<h5 className="font-bold text-white truncate text-xs">{friend.name}</h5>
<p className="text-[10px] text-slate-400 truncate mt-0.5">{friend.email}</p>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => {
if (onOpenChatWithUser) {
onClose();
onOpenChatWithUser(friend.id);
}
}}
className="p-2 bg-indigo-650/15 hover:bg-indigo-650 text-indigo-400 hover:text-white rounded-xl transition-all cursor-pointer"
title="Nhắn tin nhanh"
>
<MessageSquare className="w-4 h-4" />
</button>
<button
onClick={() => handleDisconnect(conn.id, friend.name)}
className="p-2 bg-rose-650/15 hover:bg-rose-650 text-rose-400 hover:text-white rounded-xl transition-all cursor-pointer"
title="Hủy kết bạn"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
)
) : activeTab === 'pending' ? (
pendingRequests.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<UserPlus className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Không lời mời kết bạn</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn không lời mời kết bạn nào đang chờ duyệt.</p>
</div>
</div>
) : (
<div className="space-y-2.5">
{pendingRequests.map((conn) => {
const requester = conn.requester;
return (
<div
key={conn.id}
className="p-3 bg-slate-950/40 border border-slate-850 rounded-xl flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-850 overflow-hidden flex items-center justify-center font-bold border border-slate-800 shrink-0">
{requester.avatar ? (
<img src={requester.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{requester.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="min-w-0">
<h5 className="font-bold text-white truncate text-xs">{requester.name}</h5>
<p className="text-[10px] text-slate-400 truncate mt-0.5">{requester.email}</p>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => handleUpdateStatus(conn.id, 'ACCEPTED')}
className="px-3.5 py-1.5 bg-emerald-650 hover:bg-emerald-600 font-bold text-white rounded-xl text-[10px] transition-all flex items-center gap-1 cursor-pointer"
>
<Check className="w-3.5 h-3.5" /> Chấp nhận
</button>
<button
onClick={() => handleDisconnect(conn.id, requester.name)}
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-xl transition-all cursor-pointer"
title="Từ chối lời mời"
>
<UserX className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)
) : (
// Search Tab panel
<div className="space-y-4">
<form onSubmit={handleSearchUsers} className="flex gap-2">
<div className="flex-1 relative">
<input
type="text"
placeholder="Tìm theo Tên hoặc Số điện thoại..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-2.5 pl-9 pr-4 text-white focus:outline-none focus:border-indigo-650"
/>
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3.5" />
</div>
<button
type="submit"
disabled={isSearching || !searchQuery.trim()}
className="px-4 py-2.5 bg-indigo-650 hover:bg-indigo-600 disabled:opacity-50 text-white font-bold rounded-xl transition-all flex items-center gap-1 cursor-pointer shrink-0"
>
{isSearching ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Tìm kiếm'}
</button>
</form>
<div className="space-y-2.5">
{isSearching ? (
<div className="flex justify-center items-center py-10">
<Loader2 className="w-6 h-6 text-slate-500 animate-spin" />
</div>
) : searchResults.length === 0 ? (
searchQuery.trim() && (
<div className="text-center text-slate-500 py-10 italic">
Không tìm thấy kết quả phù hợp.
</div>
)
) : (
searchResults.map((u) => {
const status = getStatusText(u.id);
return (
<div
key={u.id}
className="p-3 bg-slate-950/40 border border-slate-850 rounded-xl flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-850 overflow-hidden flex items-center justify-center font-bold border border-slate-800 shrink-0">
{u.avatar ? (
<img src={u.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{u.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="min-w-0">
<h5 className="font-bold text-white truncate text-xs">{u.name}</h5>
<p className="text-[10px] text-slate-400 truncate mt-0.5">{u.email}</p>
</div>
</div>
<div className="shrink-0">
{status === 'FRIEND' ? (
<span className="text-[10px] text-indigo-400 font-bold flex items-center gap-1">
<UserCheck className="w-3.5 h-3.5" /> Đã kết nối
</span>
) : status === 'SENT' || u.pendingSent ? (
<span className="text-[10px] text-slate-500 italic">
Đã gửi lời mời
</span>
) : status === 'RECEIVED' ? (
<span className="text-[10px] text-amber-400 font-semibold">
Chờ bạn duyệt
</span>
) : (
<button
onClick={() => handleSendRequest(u.id)}
className="px-3.5 py-1.5 bg-slate-800 hover:bg-slate-700 font-bold text-slate-200 rounded-xl text-[10px] transition-all flex items-center gap-1 cursor-pointer"
>
<UserPlus className="w-3.5 h-3.5" /> Kết nối
</button>
)}
</div>
</div>
);
})
)}
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,514 @@
import React, { useEffect, useState, useRef } from 'react';
import { X, Image as ImageIcon, Send, MapPin, Loader2, Search, Smile, Users } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
interface LiveChatModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
defaultChatUserId?: string | null;
}
export const LiveChatModal: React.FC<LiveChatModalProps> = ({
isOpen,
onClose,
user,
defaultChatUserId,
}) => {
const notify = useNotification();
const [connections, setConnections] = useState<any[]>([]);
const [activeChatUser, setActiveChatUser] = useState<any | null>(null);
const [chatMessages, setChatMessages] = useState<any[]>([]);
const [newMessage, setNewMessage] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isLoadingContacts, setIsLoadingContacts] = useState(false);
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
const [isSending, setIsSending] = useState(false);
// Attachments
const [attachedImage, setAttachedImage] = useState<File | null>(null);
const [attachedImageUrl, setAttachedImageUrl] = useState<string | null>(null);
const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const getHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`,
});
// Fetch connections
const fetchConnections = async () => {
setIsLoadingContacts(true);
try {
const res = await fetch('/api/v1/connections', { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
// Accepted connections only
const activeConns = data.filter((c: any) => c.status === 'ACCEPTED');
setConnections(activeConns);
}
} catch (e) {
console.error('[LiveChatModal] Error fetching connections:', e);
} finally {
setIsLoadingContacts(false);
}
};
// Fetch conversation messages
const fetchMessages = async (targetUserId: string) => {
setIsLoadingMessages(true);
try {
const res = await fetch(`/api/v1/messages/${targetUserId}`, { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
setChatMessages(data || []);
}
} catch (e) {
console.error('[LiveChatModal] Error fetching messages:', e);
} finally {
setIsLoadingMessages(false);
}
};
// Load contacts when modal opens
useEffect(() => {
if (isOpen && user) {
fetchConnections();
}
}, [isOpen, user]);
// Set active chat user dynamically if default is provided
useEffect(() => {
if (isOpen && defaultChatUserId && connections.length > 0) {
const conn = connections.find(
(c: any) => c.targetUser?.id === defaultChatUserId || c.requester?.id === defaultChatUserId
);
if (conn) {
const target = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
setActiveChatUser(target);
}
}
}, [isOpen, defaultChatUserId, connections]);
// Load chat messages when activeChatUser shifts
useEffect(() => {
if (activeChatUser) {
fetchMessages(activeChatUser.id);
(window as any).activeChatUserId = activeChatUser.id;
}
return () => {
(window as any).activeChatUserId = undefined;
};
}, [activeChatUser]);
// Handle incoming live messages from custom window event dispatched by App.tsx socket
useEffect(() => {
const handleMessageReceived = (e: Event) => {
const msg = (e as CustomEvent).detail;
if (activeChatUser && (msg.senderId === activeChatUser.id || msg.receiverId === activeChatUser.id)) {
setChatMessages((prev) => [...prev, msg]);
} else {
// Reload contacts to update latest message snippets/badge alerts
fetchConnections();
}
};
window.addEventListener('app:messageReceived', handleMessageReceived);
return () => {
window.removeEventListener('app:messageReceived', handleMessageReceived);
};
}, [activeChatUser]);
// Scroll to chat baseline
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages]);
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setAttachedImage(file);
setAttachedImageUrl(URL.createObjectURL(file));
notify({
title: 'Đã đính kèm ảnh',
message: `${file.name} đã được chọn để gửi.`,
type: 'info',
});
}
};
const handleAttachGps = () => {
if (!navigator.geolocation) {
notify({
title: 'Không được hỗ trợ',
message: 'Trình duyệt của bạn không hỗ trợ định vị.',
type: 'error',
});
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setAttachedLocation({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
});
notify({
title: 'Đã đính kèm định vị',
message: `Vị trí (${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}) đã được ghi nhận.`,
type: 'success',
});
},
() => {
notify({
title: 'Lỗi định vị',
message: 'Không thể lấy vị trí hiện tại. Hãy kiểm tra quyền GPS.',
type: 'error',
});
}
);
};
const handleSendMessage = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!newMessage.trim() && !attachedImage && !attachedLocation) return;
if (!activeChatUser) return;
setIsSending(true);
try {
let uploadedUrl: string | null = null;
if (attachedImage) {
const formData = new FormData();
formData.append('file', attachedImage);
const uploadRes = await fetch('/api/v1/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
body: formData,
});
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
uploadedUrl = uploadData.url;
} else {
throw new Error('Upload ảnh thất bại.');
}
}
const bodyData = {
receiverId: activeChatUser.id,
content: newMessage,
mediaUrl: uploadedUrl,
latitude: attachedLocation?.latitude || null,
longitude: attachedLocation?.longitude || null,
};
const sendRes = await fetch('/api/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify(bodyData),
});
if (sendRes.ok) {
const sentMsg = await sendRes.json();
setChatMessages((prev) => [...prev, sentMsg]);
setNewMessage('');
setAttachedImage(null);
setAttachedImageUrl(null);
setAttachedLocation(null);
} else {
throw new Error('Gửi tin nhắn thất bại.');
}
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Không thể gửi tin nhắn.',
type: 'error',
});
} finally {
setIsSending(false);
}
};
if (!isOpen || !user) return null;
// Filter connections by search query
const filteredConnections = connections.filter((conn: any) => {
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
return friend?.name?.toLowerCase().includes(searchQuery.toLowerCase());
});
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-4xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<Users className="w-5 h-5 text-indigo-400" /> Trò chuyện trực tiếp
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Workspace panel */}
<div className="flex-1 flex flex-col md:flex-row min-h-0">
{/* Left Column - Contacts Sidebar */}
<div className="w-full md:w-80 border-r border-slate-800/80 flex flex-col min-h-0 bg-slate-950/20 shrink-0">
<div className="p-3 border-b border-slate-800/50">
<div className="relative">
<input
type="text"
placeholder="Tìm kiếm bạn bè..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-950/80 border border-slate-850 rounded-xl py-2 pl-9 pr-4 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-650"
/>
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-2.5" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
{isLoadingContacts ? (
<div className="flex justify-center items-center py-10">
<Loader2 className="w-6 h-6 text-slate-500 animate-spin" />
</div>
) : filteredConnections.length === 0 ? (
<div className="text-center text-slate-500 py-10 italic">
Không tìm thấy bạn nào.
</div>
) : (
filteredConnections.map((conn: any) => {
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
const isActive = activeChatUser?.id === friend.id;
return (
<button
key={conn.id}
onClick={() => setActiveChatUser(friend)}
className={`w-full flex items-center gap-3 p-3 rounded-xl transition-all text-left cursor-pointer ${
isActive
? 'bg-indigo-650 text-white font-bold'
: 'bg-slate-950/20 hover:bg-slate-850 text-slate-300'
}`}
>
<div className="relative shrink-0">
<div className="w-10 h-10 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center overflow-hidden font-bold">
{friend.avatar ? (
<img src={friend.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{friend.name.charAt(0).toUpperCase()}</span>
)}
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-500 rounded-full border-2 border-slate-900" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs truncate font-bold">{friend.name}</div>
<div className={`text-[10px] truncate mt-0.5 ${isActive ? 'text-slate-200' : 'text-slate-500'}`}>
{friend.email}
</div>
</div>
</button>
);
})
)}
</div>
</div>
{/* Right Column - Active Chat View */}
<div className="flex-1 flex flex-col min-w-0 bg-slate-950/40">
{activeChatUser ? (
<>
{/* Active contact bar */}
<div className="px-4 py-3 bg-slate-900/60 border-b border-slate-800/60 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-800 overflow-hidden flex items-center justify-center font-bold">
{activeChatUser.avatar ? (
<img src={activeChatUser.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{activeChatUser.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="min-w-0">
<div className="font-bold text-white text-xs">{activeChatUser.name}</div>
<div className="text-[10px] text-green-400 flex items-center gap-1 font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block animate-pulse" /> Đang hoạt đng
</div>
</div>
</div>
{/* Messages stream */}
<div className="flex-1 overflow-y-auto p-4 space-y-3.5">
{isLoadingMessages ? (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
</div>
) : chatMessages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center text-slate-500 gap-1.5">
<Smile className="w-8 h-8 text-slate-600" />
<span>Hãy gửi tin nhắn đ bắt đu cuộc trò chuyện.</span>
</div>
) : (
chatMessages.map((msg: any) => {
const isMe = msg.senderId === user.id;
const dateStr = msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }) : '';
return (
<div
key={msg.id}
className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[75%] flex flex-col gap-1`}>
<div
className={`p-3 rounded-2xl break-words text-xs shadow-md ${
isMe
? 'bg-indigo-650 text-white rounded-tr-none'
: 'bg-slate-800/90 text-slate-100 rounded-tl-none'
}`}
>
{/* Attached Image */}
{msg.mediaUrl && (
<div className="mb-2 rounded-xl overflow-hidden max-w-xs border border-black/10">
<img
src={msg.mediaUrl}
alt="Chat attachment"
className="max-h-40 w-full object-cover cursor-pointer"
onClick={() => window.open(msg.mediaUrl, '_blank')}
/>
</div>
)}
{/* Attached Location */}
{msg.latitude && msg.longitude && (
<a
href={`https://maps.google.com/?q=${msg.latitude},${msg.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="mb-2 flex items-center gap-2 p-2 bg-black/20 hover:bg-black/30 rounded-xl text-[10px] text-blue-300 font-bold border border-blue-500/20"
>
<MapPin className="w-4 h-4 text-rose-500 shrink-0" />
<span>Vị trí: {msg.latitude.toFixed(5)}, {msg.longitude.toFixed(5)}</span>
</a>
)}
{msg.content && <p className="leading-relaxed whitespace-pre-wrap">{msg.content}</p>}
</div>
<span className={`text-[9px] text-slate-500 ${isMe ? 'text-right' : 'text-left'}`}>
{dateStr}
</span>
</div>
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Bottom Input Area */}
<form onSubmit={handleSendMessage} className="p-3 bg-slate-900 border-t border-slate-800/80 flex flex-col gap-2 shrink-0">
{/* Attachment Previews */}
{(attachedImageUrl || attachedLocation) && (
<div className="flex flex-wrap gap-2 p-2 bg-slate-950/60 rounded-xl border border-slate-850">
{attachedImageUrl && (
<div className="relative w-14 h-14 rounded-lg overflow-hidden border border-slate-750">
<img src={attachedImageUrl} alt="Preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => { setAttachedImage(null); setAttachedImageUrl(null); }}
className="absolute top-0.5 right-0.5 p-0.5 bg-black/70 hover:bg-black text-white rounded-full"
>
<X className="w-3 h-3" />
</button>
</div>
)}
{attachedLocation && (
<div className="flex items-center gap-1.5 px-3 py-1 bg-rose-950/30 text-rose-300 border border-rose-800/30 rounded-xl text-[10px] font-bold">
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0" />
<span>Vị trí GPS</span>
<button
type="button"
onClick={() => setAttachedLocation(null)}
className="p-0.5 bg-rose-900/50 hover:bg-rose-900 text-white rounded-full ml-1"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="p-2.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-xl transition-all cursor-pointer shrink-0"
title="Đính kèm ảnh"
>
<ImageIcon className="w-4 h-4" />
</button>
<input
type="file"
ref={fileInputRef}
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
<button
type="button"
onClick={handleAttachGps}
className="p-2.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-xl transition-all cursor-pointer shrink-0"
title="Gửi vị trí định vị"
>
<MapPin className="w-4 h-4 text-rose-500" />
</button>
<input
type="text"
placeholder="Nhập tin nhắn..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl py-2.5 px-4 text-white focus:outline-none focus:border-indigo-650"
/>
<button
type="submit"
disabled={isSending || (!newMessage.trim() && !attachedImage && !attachedLocation)}
className="p-2.5 bg-indigo-650 hover:bg-indigo-600 disabled:opacity-50 text-white rounded-xl transition-all cursor-pointer shrink-0"
>
{isSending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</button>
</div>
</form>
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center p-8 gap-3">
<div className="w-16 h-16 bg-slate-900 border border-slate-800 rounded-full flex items-center justify-center">
<Smile className="w-8 h-8 text-slate-600 animate-bounce" />
</div>
<div>
<div className="font-bold text-white text-sm">Chưa chọn bạn hội thoại</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Chọn một người bạn danh sách bên trái đ bắt đu cuộc trò chuyện riêng của bạn.</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,308 @@
import React, { useEffect, useState } from 'react';
import { X, Calendar, Users, Navigation, Trash2, Loader2, Compass, Play } from 'lucide-react';
import { useTourStore } from '../../store/useTourStore';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
interface MyToursModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
onViewTour: (tourId: string) => void;
onOpenNavigation?: (payload: any) => void;
}
export const MyToursModal: React.FC<MyToursModalProps> = ({
isOpen,
onClose,
user,
onViewTour,
onOpenNavigation,
}) => {
const notify = useNotification();
const confirm = useConfirm();
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const deleteTour = useTourStore(state => state.deleteTour);
const [isLoading, setIsLoading] = useState(false);
const [isDeletingId, setIsDeletingId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'ongoing' | 'upcoming' | 'past'>('ongoing');
useEffect(() => {
if (isOpen && user) {
const loadTours = async () => {
setIsLoading(true);
try {
await fetchPublicTours();
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
loadTours();
}
}, [isOpen, fetchPublicTours, user]);
if (!isOpen || !user) return null;
// Filter tours where user is participant
const myTours = publicTours.filter(tour =>
tour.participants?.some((p: any) => p.userId === user?.id)
);
const now = new Date();
// Classify tours
const ongoingTours = myTours.filter(tour => {
if (!tour.startDate || !tour.endDate) return false;
const start = new Date(tour.startDate);
const end = new Date(tour.endDate);
return start <= now && end >= now;
});
const upcomingTours = myTours.filter(tour => {
if (!tour.startDate) return true;
const start = new Date(tour.startDate);
return start > now;
});
const pastTours = myTours.filter(tour => {
if (!tour.endDate) return false;
const end = new Date(tour.endDate);
return end < now;
});
const getActiveList = () => {
switch (activeTab) {
case 'ongoing': return ongoingTours;
case 'upcoming': return upcomingTours;
case 'past': return pastTours;
}
};
const handleDelete = async (tourId: string, tourTitle: string) => {
const ok = await confirm({
title: 'Xóa chuyến đi?',
message: `Bạn có chắc chắn muốn xóa chuyến đi "${tourTitle}" không? Hành động này không thể hoàn tác.`,
});
if (!ok) return;
setIsDeletingId(tourId);
try {
await deleteTour(tourId);
notify({
title: 'Thành công',
message: 'Đã xóa chuyến đi thành công.',
type: 'success',
});
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Xóa chuyến đi thất bại.',
type: 'error',
});
} finally {
setIsDeletingId(null);
}
};
const handleStartNavigation = (tour: any) => {
if (!onOpenNavigation) {
notify({
title: 'Lưu ý',
message: 'Tính năng dẫn đường chỉ khả dụng trong chế độ bản đồ.',
type: 'info',
});
return;
}
const firstLeg = tour.legs?.[0];
const origin = firstLeg?.locations?.[0] || { lat: 10.7769, lng: 106.7009 };
const destination = firstLeg?.locations?.[firstLeg?.locations?.length - 1] || { lat: 10.8231, lng: 106.6297, name: 'Điểm kết thúc' };
onOpenNavigation({
tourId: tour.id,
origin: { lat: Number(origin.latitude || origin.lat), lng: Number(origin.longitude || origin.lng) },
destination: {
lat: Number(destination.latitude || destination.lat),
lng: Number(destination.longitude || destination.lng),
name: destination.name || 'Điểm kết thúc'
},
tourTitle: tour.title,
});
onClose();
};
// Calculate ongoing timeline progress percentage
const getProgressPercent = (tour: any) => {
if (!tour.startDate || !tour.endDate) return 0;
const start = new Date(tour.startDate).getTime();
const end = new Date(tour.endDate).getTime();
const current = now.getTime();
if (current >= end) return 100;
if (current <= start) return 0;
return Math.round(((current - start) / (end - start)) * 100);
};
const currentList = getActiveList();
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-2xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<Compass className="w-5 h-5 text-indigo-400" /> Hành trình của tôi
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tab Selection Header */}
<div className="bg-slate-950/40 p-2.5 border-b border-slate-850 flex gap-2 shrink-0">
{[
{ id: 'ongoing', label: 'Đang thực hiện' },
{ id: 'upcoming', label: 'Sắp khởi hành' },
{ id: 'past', label: 'Đã hoàn thành' }
].map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 py-2 rounded-xl text-center font-bold transition-all cursor-pointer ${isActive
? 'bg-indigo-650 text-white shadow-md'
: 'bg-slate-900/40 hover:bg-slate-850 text-slate-400'
}`}
>
{tab.label}
</button>
);
})}
</div>
{/* Content body */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading ? (
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
<span className="text-slate-400 font-medium">Đang tải danh sách chuyến đi...</span>
</div>
) : currentList.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<Compass className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Chưa hành trình nào</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn không chuyến đi nào trong mục này.</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{currentList.map((tour) => {
const isOwner = tour.participants?.some(
(p: any) => p.userId === user?.id && p.role === 'OWNER'
);
const startDateStr = tour.startDate ? new Date(tour.startDate).toLocaleDateString('vi-VN') : '';
const endDateStr = tour.endDate ? new Date(tour.endDate).toLocaleDateString('vi-VN') : '';
const progress = getProgressPercent(tour);
// Fallback tour banner photo
const tourBanner = tour.coverImage || 'https://images.unsplash.com/photo-1488646953014-85cb44e25828?w=500';
return (
<div
key={tour.id}
className="bg-slate-950/40 border border-slate-850 hover:border-slate-750 rounded-2xl overflow-hidden flex flex-col justify-between transition-all"
>
{/* Banner cover */}
<div className="relative h-28 w-full overflow-hidden">
<img src={tourBanner} alt="Cover Banner" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 to-transparent opacity-90" />
<div className="absolute bottom-3 left-3 right-3 min-w-0">
<h4 className="font-bold text-white text-xs truncate drop-shadow-md">{tour.title}</h4>
<p className="text-[10px] text-slate-300 font-medium mt-0.5">{startDateStr} - {endDateStr}</p>
</div>
</div>
{/* Progress details */}
<div className="p-3.5 space-y-3 flex-1 flex flex-col justify-between">
{activeTab === 'ongoing' && (
<div className="space-y-1">
<div className="flex justify-between items-center text-[10px] text-slate-400 font-bold">
<span>Tiến đ hành trình</span>
<span className="text-indigo-400">{progress}%</span>
</div>
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
<div className="bg-indigo-500 h-full rounded-full transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
<div className="flex items-center gap-3 text-[10px] text-slate-400 font-semibold">
<span className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 text-slate-500" />
{tour.participants?.length || 0} thành viên
</span>
<span className="flex items-center gap-1.5">
<Navigation className="w-3.5 h-3.5 text-slate-500" />
{tour.legs?.length || 0} chặng đi
</span>
</div>
<div className="flex justify-end items-center gap-2 border-t border-slate-850/60 pt-3 mt-1">
{isOwner && (
<button
onClick={() => handleDelete(tour.id, tour.title)}
disabled={isDeletingId === tour.id}
className="p-2 text-rose-400 hover:bg-rose-500/10 rounded-xl transition-all cursor-pointer disabled:opacity-50"
title="Xóa chuyến đi"
>
{isDeletingId === tour.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
)}
<button
onClick={() => handleStartNavigation(tour)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 font-bold text-indigo-300 rounded-xl transition-all active:scale-95 cursor-pointer"
title="Bắt đầu dẫn đường"
>
<Play className="w-3 h-3 text-indigo-400 fill-indigo-400" /> Dẫn đưng
</button>
<button
onClick={() => {
onClose();
onViewTour(tour.id);
}}
className="flex items-center gap-1.5 px-3.5 py-1.5 bg-indigo-650 hover:bg-indigo-600 font-bold text-white rounded-xl transition-all active:scale-95 cursor-pointer shadow-md shadow-indigo-900/10"
>
Hành trình Tour
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,496 @@
import React, { useEffect, useState } from 'react';
import { X, Image as ImageIcon, Loader2, Download, Eye, MapPin, Tag, Trash2, Edit2, Check } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
interface PhotoGalleryModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
}
const PHOTO_TAGS = [
{ value: 'phong-canh', label: '🏞️ Phong cảnh' },
{ value: 'con-nguoi', label: '👥 Con người' },
{ value: 'doi-thuong', label: '🎒 Đời thường' },
{ value: 'bien', label: '🌊 Biển' },
{ value: 'nui', label: '⛰️ Núi' },
{ value: 'do-thi', label: '🏙️ Đô thị' },
{ value: 'thuc-an', label: '🍜 Thức ăn' },
{ value: 'cho', label: '🛍️ Chợ' },
{ value: 'hien-dai', label: '🏗️ Hiện đại' },
{ value: 'dong-vat', label: '🦁 Động vật' },
{ value: 'thu-cung', label: '🐕 Thú cưng' }
];
export const PhotoGalleryModal: React.FC<PhotoGalleryModalProps> = ({
isOpen,
onClose,
user,
}) => {
const notify = useNotification();
const confirm = useConfirm();
const [photos, setPhotos] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Filtering states
const [filterTourId, setFilterTourId] = useState<string>('all');
const [filterTag, setFilterTag] = useState<string>('all');
// Preview / Editor States
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
const [editingPhotoId, setEditingPhotoId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editTags, setEditTags] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isDeletingId, setIsDeletingId] = useState<string | null>(null);
const getHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`,
});
const loadPhotos = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/v1/users/me/photos', { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
setPhotos(data || []);
} else {
throw new Error('Không thể tải thư viện ảnh.');
}
} catch (e: any) {
console.error(e);
notify({
title: 'Lỗi',
message: e.message || 'Không thể tải ảnh.',
type: 'error',
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isOpen && user) {
loadPhotos();
}
}, [isOpen, user]);
if (!isOpen || !user) return null;
// Extract unique tours from photos list for dropdown filtering
const uniqueToursMap = new Map();
photos.forEach(p => {
if (p.tour?.id && p.tour?.title) {
uniqueToursMap.set(p.tour.id, p.tour.title);
}
});
const uniqueTours = Array.from(uniqueToursMap.entries()).map(([id, title]) => ({ id, title }));
// Handle Photo Deletion
const handleDelete = async (photoId: string) => {
const ok = await confirm({
title: 'Xóa ảnh?',
message: 'Bạn có chắc chắn muốn xóa bức ảnh này không? Ảnh sẽ được chuyển vào thùng rác.',
});
if (!ok) return;
setIsDeletingId(photoId);
try {
const res = await fetch(`/api/v1/photos/${photoId}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (res.ok) {
notify({
title: 'Thành công',
message: 'Đã xóa ảnh thành công.',
type: 'success',
});
setPhotos(prev => prev.filter(p => p.id !== photoId));
if (selectedPhoto?.id === photoId) setSelectedPhoto(null);
} else {
throw new Error('Xóa ảnh thất bại.');
}
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Không thể xóa ảnh.',
type: 'error',
});
} finally {
setIsDeletingId(null);
}
};
// Start Editing Tag details
const startEdit = (photo: any) => {
setEditingPhotoId(photo.id);
const meta = photo.metadata || {};
setEditTitle(meta.title || '');
setEditDescription(meta.description || '');
setEditTags(meta.tags || []);
};
const handleTagToggle = (tagValue: string) => {
setEditTags(prev =>
prev.includes(tagValue)
? prev.filter(t => t !== tagValue)
: [...prev, tagValue]
);
};
// Save Tagging Details
const saveEdit = async (photoId: string) => {
setIsSaving(true);
try {
const res = await fetch(`/api/v1/photos/${photoId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({
title: editTitle,
description: editDescription,
tags: editTags,
}),
});
if (res.ok) {
notify({
title: 'Thành công',
message: 'Cập nhật thông tin ảnh thành công.',
type: 'success',
});
// Reload photos list to synchronize
await loadPhotos();
setEditingPhotoId(null);
} else {
throw new Error('Lỗi cập nhật ảnh.');
}
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Cập nhật thất bại.',
type: 'error',
});
} finally {
setIsSaving(false);
}
};
const handleDownload = async (url: string, filename: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error(e);
notify({
title: 'Lỗi tải về',
message: 'Không thể tải trực tiếp ảnh xuống thiết bị.',
type: 'error',
});
}
};
// Filter photos
const filteredPhotos = photos.filter((photo) => {
const matchTour = filterTourId === 'all' || photo.tour?.id === filterTourId;
const matchTag = filterTag === 'all' || photo.metadata?.tags?.includes(filterTag);
return matchTour && matchTag;
});
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-4xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-emerald-400" /> Thư viện nh
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Dual Filter Header Select Pinned Matrix */}
<div className="bg-slate-950/40 p-3.5 border-b border-slate-850 flex flex-col sm:flex-row gap-3.5 shrink-0">
<div className="flex-1 flex flex-col gap-1.5">
<label className="font-bold text-slate-400 text-[10px] uppercase tracking-wider">Theo hành trình:</label>
<select
value={filterTourId}
onChange={(e) => setFilterTourId(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white font-bold cursor-pointer focus:outline-none focus:border-indigo-650"
>
<option value="all">Tất cả hành trình</option>
{uniqueTours.map((t) => (
<option key={t.id} value={t.id}>{t.title}</option>
))}
</select>
</div>
<div className="flex-1 flex flex-col gap-1.5">
<label className="font-bold text-slate-400 text-[10px] uppercase tracking-wider">Theo thẻ phân loại:</label>
<select
value={filterTag}
onChange={(e) => setFilterTag(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white font-bold cursor-pointer focus:outline-none focus:border-indigo-650"
>
<option value="all">Tất cả thẻ tags</option>
{PHOTO_TAGS.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
{/* Content body grid workspace */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading ? (
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
<Loader2 className="w-8 h-8 text-emerald-400 animate-spin" />
<span className="text-slate-400 font-medium">Đang tải thư viện nh...</span>
</div>
) : filteredPhotos.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<ImageIcon className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Không tìm thấy bức nh nào</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Không nh nào khớp với bộ lọc hiện tại.</p>
</div>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{filteredPhotos.map((photo) => {
const photoTagsList = photo.metadata?.tags || [];
return (
<div
key={photo.id}
className="group relative aspect-square rounded-2xl overflow-hidden bg-slate-950 border border-slate-850 hover:border-slate-700 shadow-lg transition-all flex flex-col"
>
<img
src={photo.imageUrl}
alt="Gallery Item"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
{/* Standard tags badge dot count */}
{photoTagsList.length > 0 && (
<div className="absolute top-2.5 left-2.5 bg-black/60 backdrop-blur-md text-white font-bold text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1 border border-white/10 z-10">
<Tag className="w-2.5 h-2.5 text-emerald-400 shrink-0" />
<span>{photoTagsList.length} tags</span>
</div>
)}
{/* Hover controls overlay */}
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 flex flex-col justify-between p-3.5 transition-opacity duration-250 z-10">
<div className="flex justify-end gap-1.5">
<button
onClick={() => startEdit(photo)}
className="p-2 bg-slate-900/85 hover:bg-amber-650 text-white rounded-xl transition-colors cursor-pointer"
title="Sửa thông tin"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(photo.id)}
disabled={isDeletingId === photo.id}
className="p-2 bg-slate-900/85 hover:bg-rose-650 text-white rounded-xl transition-colors cursor-pointer disabled:opacity-50"
title="Xóa ảnh"
>
{isDeletingId === photo.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={() => setSelectedPhoto(photo)}
className="p-2 bg-slate-900/85 hover:bg-indigo-650 text-white rounded-xl transition-colors cursor-pointer"
title="Xem chi tiết"
>
<Eye className="w-3.5 h-3.5" />
</button>
</div>
<div className="min-w-0">
<div className="text-[10px] font-bold text-white truncate">
{photo.metadata?.title || 'Chưa đặt tiêu đề'}
</div>
{photo.tour?.title && (
<div className="text-[9px] font-bold text-indigo-300 truncate flex items-center gap-1 mt-0.5">
<MapPin className="w-2.5 h-2.5 shrink-0" />
{photo.tour.title}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Editor & Tag modification Dialog */}
{editingPhotoId && (
<div className="fixed inset-0 z-[1000001] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 pointer-events-auto">
<div className="bg-slate-900 w-full max-w-md rounded-2xl overflow-hidden border border-slate-800 p-5 space-y-4 shadow-2xl">
<div className="flex justify-between items-center pb-2 border-b border-slate-800">
<span className="font-bold text-sm text-white">Chỉnh sửa thông tin nh</span>
<button
onClick={() => setEditingPhotoId(null)}
className="p-1 hover:bg-slate-800 text-slate-400 hover:text-white rounded-lg transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3 text-xs">
<div className="flex flex-col gap-1">
<label className="font-bold text-slate-400">Tiêu đ nh:</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Ví dụ: Hoàng hôn biển Ba Động..."
className="bg-slate-950 border border-slate-805 rounded-xl p-2.5 text-white focus:outline-none focus:border-indigo-650"
/>
</div>
<div className="flex flex-col gap-1">
<label className="font-bold text-slate-400"> tả nh:</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Ghi lại kỷ niệm..."
rows={2}
className="bg-slate-950 border border-slate-805 rounded-xl p-2.5 text-white resize-none focus:outline-none focus:border-indigo-650"
/>
</div>
<div className="space-y-1.5">
<label className="font-bold text-slate-400 block mb-1">Gắn thẻ phân loại (Hashtags):</label>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto p-1.5 bg-slate-950/60 border border-slate-850 rounded-xl">
{PHOTO_TAGS.map((tag) => {
const isSelected = editTags.includes(tag.value);
return (
<button
key={tag.value}
type="button"
onClick={() => handleTagToggle(tag.value)}
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all cursor-pointer flex items-center gap-1 ${
isSelected
? 'bg-emerald-950/40 text-emerald-400 border-emerald-500/50'
: 'bg-slate-900 border-slate-800 text-slate-400 hover:border-slate-700'
}`}
>
{isSelected && <Check className="w-3 h-3 text-emerald-400 shrink-0" />}
{tag.label}
</button>
);
})}
</div>
</div>
</div>
<div className="pt-3 border-t border-slate-850 flex justify-end gap-2">
<button
onClick={() => setEditingPhotoId(null)}
className="px-4 py-2 bg-slate-850 hover:bg-slate-800 font-bold text-slate-300 rounded-xl"
>
Hủy
</button>
<button
onClick={() => saveEdit(editingPhotoId)}
disabled={isSaving}
className="px-4 py-2 bg-indigo-650 hover:bg-indigo-600 font-bold text-white rounded-xl flex items-center gap-1.5 disabled:opacity-50"
>
{isSaving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
Lưu lại
</button>
</div>
</div>
</div>
)}
{/* Fullscreen Preview overlay */}
{selectedPhoto && (
<div className="fixed inset-0 z-[2000000] bg-black/95 flex flex-col justify-between p-4 pointer-events-auto">
{/* Close trigger top bar */}
<div className="flex justify-between items-center w-full pb-3 border-b border-slate-900">
<div className="text-white font-bold text-xs truncate">
{selectedPhoto.metadata?.title || 'Xem ảnh'}
</div>
<button
onClick={() => setSelectedPhoto(null)}
className="p-2 bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white rounded-xl cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Fullscreen Photo view */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-2xl w-full flex flex-col gap-3">
<img
src={selectedPhoto.originalUrl || selectedPhoto.imageUrl}
alt="Fullscreen Preview"
className="max-w-full max-h-[60vh] object-contain rounded-xl shadow-2xl mx-auto"
/>
<div className="bg-slate-900/60 border border-slate-800 p-4 rounded-2xl space-y-1.5">
{selectedPhoto.metadata?.title && (
<h4 className="font-bold text-white text-xs">{selectedPhoto.metadata.title}</h4>
)}
{selectedPhoto.metadata?.description && (
<p className="text-slate-400 text-[10px]">{selectedPhoto.metadata.description}</p>
)}
<div className="flex flex-wrap gap-1.5 mt-2">
{selectedPhoto.metadata?.tags?.map((t: string) => (
<span
key={t}
className="px-2 py-0.5 bg-slate-950/80 border border-slate-800 text-slate-400 text-[9px] font-bold rounded-full"
>
#{t}
</span>
))}
</div>
</div>
</div>
</div>
{/* Action bottom bar */}
<div className="flex justify-center items-center py-4 border-t border-slate-900 gap-3">
<button
onClick={() => handleDownload(selectedPhoto.originalUrl || selectedPhoto.imageUrl, `yotrip-photo-${selectedPhoto.id}.jpg`)}
className="flex items-center gap-2 px-5 py-3 bg-emerald-650 hover:bg-emerald-600 font-bold text-white rounded-xl shadow-lg transition-all active:scale-95 cursor-pointer"
>
<Download className="w-4 h-4" /> Tải về tệp gốc (.jpg)
</button>
</div>
</div>
)}
</div>
);
};
+72 -4
View File
@@ -16,6 +16,10 @@ import { PublicPhotoModal } from '../components/PublicPhotoModal';
import { MapProfileDropdown } from '@/components/MapProfileDropdown';
import { ProfileSettingsModal } from '@/components/ProfileSettingsModal';
import { LoginModal } from '@/components/LoginModal';
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';
// Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({
@@ -69,7 +73,9 @@ function MapTracker() {
return null;
}
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess, onGoToDashboard }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void, onGoToDashboard?: () => void }) => {
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess, onGoToDashboard, onOpenNavigation }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onLoginSuccess?: (user: any) => void, onGoToDashboard?: (tab?: 'tours' | 'connections' | 'photos' | 'chats') => void, onOpenNavigation?: (payload: any) => void }) => {
const guestToken = localStorage.getItem('guest_token');
const isAuthenticated = !!user && !guestToken;
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
@@ -223,6 +229,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = 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 [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
@@ -409,6 +420,12 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
if (user || localStorage.getItem('token')) {
fetchPublicTours();
}
const targetTourId = localStorage.getItem('viewTourOnLand');
if (targetTourId) {
localStorage.removeItem('viewTourOnLand');
onViewTour(targetTourId);
}
}, []);
// Chỉ lấy vị trí GPS ban đầu để hiển thị marker, KHÔNG tự động nhảy bản đồ đến vị trí đó
@@ -771,16 +788,32 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</div>
{/* Nhóm bên phải: Menu cá nhân hợp nhất */}
<div className="flex items-center gap-2 pointer-events-auto">
<div className="flex items-center gap-3 pointer-events-auto">
{isAuthenticated && (
<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}
onGoToDashboard={onGoToDashboard}
onOpenSettings={() => setIsProfileSettingsOpen(true)}
onOpenCreateTour={() => setIsCreateModalOpen(true)}
onOpenReport={() => setIsReportModalOpen(true)}
onOpenLogin={() => setIsLoginModalOpen(true)}
onOpenMyPhotos={onOpenMyPhotos}
onOpenMyPhotos={() => setIsMyPhotosOpen(true)}
onOpenMyTours={() => setIsMyToursOpen(true)}
onOpenFriends={() => setIsFriendsOpen(true)}
onOpenAdmin={() => setIsAdminModalOpen(true)}
/>
</div>
@@ -1566,6 +1599,41 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
}}
/>
{/* My Tours Modal */}
<MyToursModal
isOpen={isMyToursOpen}
onClose={() => setIsMyToursOpen(false)}
user={user}
onViewTour={onViewTour}
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);
}}
/>
{/* Floating GPS positioning button */}
<button
onClick={requestGpsPosition}
+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>
);
};
+10 -2
View File
@@ -37,13 +37,15 @@ interface MemberDashboardProps {
onExploreTours: () => void;
onViewTour: (tourId: string, fromPage?: 'explore' | 'dashboard') => void;
onOpenMyPhotos: () => void;
initialTab?: 'tours' | 'connections' | 'photos' | 'chats';
}
export const MemberDashboard: React.FC<MemberDashboardProps> = ({
user,
onLogout,
onExploreTours,
onViewTour
onViewTour,
initialTab,
}) => {
// GUARD: Prevent guest users from accessing dashboard
const guestToken = localStorage.getItem('guest_token');
@@ -68,7 +70,13 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const [activeTab, setActiveTab] = useState<'tours' | 'connections' | 'photos' | 'chats'>('tours');
const [activeTab, setActiveTab] = useState<'tours' | 'connections' | 'photos' | 'chats'>(initialTab || 'tours');
useEffect(() => {
if (initialTab) {
setActiveTab(initialTab);
}
}, [initialTab]);
const [connectionSubTab, setConnectionSubTab] = useState<'list' | 'search' | 'pending'>('list');
// Connection states