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

This commit is contained in:
2026-06-21 21:53:14 +07:00
parent 403c169ddd
commit b1a539235b
40 changed files with 5094 additions and 668 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
+28
View File
@@ -0,0 +1,28 @@
FROM node:20-alpine AS base
WORKDIR /usr/src/app
COPY package*.json ./
# Development stage
FROM base AS development
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# Build stage for production
FROM base AS build
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
RUN npm install
COPY . .
RUN npm run build
# Production stage using Nginx
FROM nginx:1.25-alpine AS production
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Proxy requests to backend for deployment on same server/domain (optional but good practice)
location /api/v1/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Proxy WebSocket connection
location /socket.io/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+4
View File
@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"date-fns": "^4.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^0.284.0",
"react": "^18.3.1",
@@ -25,6 +28,7 @@
"@tailwindcss/postcss": "^4.3.1",
"@types/leaflet": "^1.9.12",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
+1 -1
View File
@@ -179,7 +179,7 @@ function App() {
}
if (currentPage === 'notes') {
return <MyNotePage onBack={() => setCurrentPage('tourDetail')} />;
return <MyNotePage tourId={currentTourId!} onBack={() => setCurrentPage('tourDetail')} />;
}
if (currentPage === 'explore') {
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { format, parseISO } from 'date-fns';
import { X, MapPin, Loader2, Clock, Map as MapIcon, Navigation, Search } from 'lucide-react';
import { X, MapPin, Loader2, Map as MapIcon, Navigation, Search } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
import { useNotification } from '@/hooks/useNotification';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
+5 -1
View File
@@ -3,6 +3,7 @@ import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useTourStore } from '@/store/useTourStore';
import { processImageModeration } from '@/hooks/useImageModeration';
import { compressImage } from '../utils/image';
interface AddPhotoModalProps {
isOpen: boolean;
@@ -39,8 +40,11 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
continue;
}
// Nén ảnh trước
const compressedFile = await compressImage(file);
// 2. Chạy kiểm duyệt hình ảnh
const moderationResult = await processImageModeration(file);
const moderationResult = await processImageModeration(compressedFile);
if (moderationResult.blocked) {
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
continue;
+3 -2
View File
@@ -9,7 +9,7 @@ interface Comment {
userName: string;
content: string;
createdAt: string;
userId: string;
userId?: string;
}
interface CommentModalProps {
@@ -81,7 +81,8 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
id: newCommentData.id,
userName: newCommentData.user?.name || 'Ẩn danh',
content: newCommentData.content,
createdAt: newCommentData.createdAt
createdAt: newCommentData.createdAt,
userId: newCommentData.userId || newCommentData.user?.id
}];
});
}
File diff suppressed because one or more lines are too long
+40 -19
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
@@ -63,7 +63,6 @@ export const ItineraryTimeline = ({
// Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop
const deleteLeg = useTourStore(state => state.deleteLeg);
const initializeLegs = useTourStore(state => state.initializeLegs);
const fetchTour = useTourStore(state => state.fetchTour);
const deleteLocation = useTourStore(state => state.deleteLocation);
// Khai báo logic canEdit để sử dụng trong toàn bộ component
@@ -80,9 +79,9 @@ export const ItineraryTimeline = ({
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
const handleCommentIncrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
const updatedLegs = currentLegs.map((leg: any) => ({
...leg,
locations: leg.locations.map(loc =>
locations: leg.locations.map((loc: any) =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
: loc
@@ -94,9 +93,9 @@ export const ItineraryTimeline = ({
const handleCommentDecrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
const updatedLegs = currentLegs.map((leg: any) => ({
...leg,
locations: leg.locations.map(loc =>
locations: leg.locations.map((loc: any) =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
: loc
@@ -305,7 +304,7 @@ export const ItineraryTimeline = ({
<div className="ml-2">
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
{legIdx === 0 && !leg.locations.some(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
{legIdx === 0 && !leg.locations.some((loc: any) => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
<div className="z-10 mt-1.5 mr-4">
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-blue-200 flex items-center justify-center text-blue-400">
@@ -326,7 +325,7 @@ export const ItineraryTimeline = ({
)}
{/* Nút thêm nhanh "Điểm kết thúc" cho Chặng cuối nếu chưa có */}
{legIdx === legs.length - 1 && !legs.some(l => l.locations.some(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
{legIdx === legs.length - 1 && !legs.some((l: any) => l.locations.some((loc: any) => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
<div className="z-10 mt-1.5 mr-4">
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-red-200 flex items-center justify-center text-red-400">
@@ -346,9 +345,9 @@ export const ItineraryTimeline = ({
</div>
)}
{leg.locations.map((location, idx) => {
{leg.locations.map((location: any) => {
// Tìm vị trí của điểm này trong toàn bộ hành trình
const globalIdx = allLocations.findIndex(loc => loc.id === location.id);
const globalIdx = allLocations.findIndex((loc: any) => loc.id === location.id);
const prevLocation = globalIdx > 0 ? allLocations[globalIdx - 1] : null;
const distanceFromPrev = prevLocation
@@ -386,9 +385,12 @@ export const ItineraryTimeline = ({
</div>
{/* Card Content */}
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
}`}>
<div
onClick={() => onNavigate?.(location)}
className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 cursor-pointer ${
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
}`}
>
<div className="flex justify-between items-start">
<div>
{isStartPoint && (
@@ -398,7 +400,10 @@ export const ItineraryTimeline = ({
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
)}
<h3
onClick={() => onNavigate?.(location)}
onClick={(e) => {
e.stopPropagation();
onNavigate?.(location);
}}
className={`font-semibold text-lg cursor-pointer hover:text-blue-600 transition-colors select-none ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}
>
{location.name}
@@ -437,11 +442,14 @@ export const ItineraryTimeline = ({
)}
</div>
<div className="text-right flex flex-col items-end">
<div className="text-right flex flex-col items-end" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1 mb-2">
{onQuickNote && !isPublicView && (
<button
onClick={() => onQuickNote(location.name)}
onClick={(e) => {
e.stopPropagation();
onQuickNote(location.name);
}}
className="flex items-center gap-1 px-2 py-1 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-bold transition-all border border-amber-100"
title="Ghi chú nhanh"
>
@@ -449,7 +457,8 @@ export const ItineraryTimeline = ({
</button>
)}
<button
onClick={() => {
onClick={(e) => {
e.stopPropagation();
setCommentLocationId(location.id);
setCommentLocationName(location.name);
setIsCommentModalOpen(true);
@@ -471,10 +480,22 @@ export const ItineraryTimeline = ({
)}
{canEdit && ( // Allow editing and deleting of all locations if user has edit permissions
<div className="flex gap-1 mt-2">
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
<button
onClick={(e) => {
e.stopPropagation();
onEditLocation?.(location);
}}
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDeleteLocation(location.id)} className="p-1 text-gray-400 hover:text-red-600 transition-colors">
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteLocation(location.id);
}}
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
+4 -4
View File
@@ -150,7 +150,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
/>
{/* Modal Content */}
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-300">
<div className="p-8 sm:p-10">
<div className="flex justify-between items-start mb-8">
<div>
@@ -173,12 +173,12 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-semibold text-gray-700 ml-1">Email</label>
<label className="text-sm font-semibold text-gray-700 ml-1">Tài khoản hoặc Email</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="email"
placeholder="name@example.com"
type="text"
placeholder="admin hoặc email..."
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
import { CheckCircle, AlertCircle, Info } from 'lucide-react';
interface NotificationModalProps {
isOpen: boolean;
+21 -4
View File
@@ -64,6 +64,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
useEffect(() => {
const lat = photo?.metadata?.lat;
@@ -441,11 +442,15 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<img
src={photo.imageUrl}
alt="Public Map Upload"
className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
draggable={false}
/>
{/* Shield overlay to prevent Save Image As on right-click / long press for non-owners */}
{!isAuthorized && (
{(!isAuthorized || !isLoggedIn) && (
<div className="absolute inset-0 bg-transparent select-none z-10" />
)}
</a>
@@ -472,7 +477,15 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
isActive ? 'border-emerald-500 scale-110 shadow-lg' : 'border-slate-700 hover:border-slate-500'
}`}
>
<img src={p.imageUrl} alt="Timeline thumbnail" className="w-full h-full object-cover" />
<img
src={p.imageUrl}
alt="Timeline thumbnail"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`w-full h-full object-cover ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
/>
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[8px] text-center font-bold text-slate-300 py-0.5">
{new Date(p.capturedAt).toLocaleDateString('vi-VN', { month: '2-digit', day: '2-digit' })}
</div>
@@ -775,7 +788,11 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<img
src={photo.imageUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200 ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
/>
</div>
)}
@@ -0,0 +1,288 @@
import React, { useState } from 'react';
import { X, ShieldAlert, MapPin, Loader2, Phone, Mail, AlertTriangle } from 'lucide-react';
import { useTranslation } from '../hooks/useTranslation';
interface ReportBusinessModalProps {
isOpen: boolean;
onClose: () => void;
initialLatitude?: number;
initialLongitude?: number;
}
export const ReportBusinessModal: React.FC<ReportBusinessModalProps> = ({
isOpen,
onClose,
initialLatitude,
initialLongitude
}) => {
const { t } = useTranslation();
const [type, setType] = useState('RESTAURANT');
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [latitude, setLatitude] = useState(initialLatitude ? String(initialLatitude) : '');
const [longitude, setLongitude] = useState(initialLongitude ? String(initialLongitude) : '');
const [reason, setReason] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
React.useEffect(() => {
if (isOpen) {
setLatitude(initialLatitude ? String(initialLatitude) : '');
setLongitude(initialLongitude ? String(initialLongitude) : '');
setSuccess(false);
setError('');
}
}, [isOpen, initialLatitude, initialLongitude]);
if (!isOpen) return null;
const handleGetCurrentLocation = () => {
if (!navigator.geolocation) {
setError('Trình duyệt không hỗ trợ định vị GPS.');
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(String(position.coords.latitude.toFixed(6)));
setLongitude(String(position.coords.longitude.toFixed(6)));
},
() => {
setError('Không thể lấy vị trí hiện tại. Vui lòng bật định vị GPS.');
}
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/reports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
name,
phone: phone || null,
email: email || null,
address: address || null,
latitude: latitude ? parseFloat(latitude) : null,
longitude: longitude ? parseFloat(longitude) : null,
reason,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Gửi báo cáo thất bại.');
}
setSuccess(true);
setTimeout(() => {
onClose();
// Reset form
setName('');
setPhone('');
setEmail('');
setAddress('');
setLatitude('');
setLongitude('');
setReason('');
}, 2000);
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Content Container */}
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col max-h-[90vh]">
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-50 text-red-500 rounded-xl">
<ShieldAlert className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">{t('reportModalTitle')}</h2>
<p className="text-xs text-gray-500 mt-0.5">Báo cáo các hành vi không lành mạnh hoặc lừa đo kinh doanh.</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
{success ? (
<div className="p-10 flex flex-col items-center justify-center text-center space-y-4">
<div className="w-16 h-16 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center shadow-lg animate-bounce">
<ShieldAlert className="w-8 h-8" />
</div>
<h3 className="text-xl font-bold text-gray-900">{t('success')}!</h3>
<p className="text-sm text-gray-500 max-w-sm">{t('reportSuccess')}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1 text-left">
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-2xl text-xs font-bold flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Loại hình */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">{t('businessType')} *</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm font-bold text-gray-800"
>
<option value="USER">{t('typeUser')}</option>
<option value="RESTAURANT">{t('typeRestaurant')}</option>
<option value="HOTEL">{t('typeHotel')}</option>
<option value="HOMESTAY">{t('typeHomestay')}</option>
</select>
</div>
{/* Tên */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">{t('businessName')} *</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="VD: Nhà hàng ABC, Homestay X..."
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Số điện thoại */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
<Phone className="w-3.5 h-3.5 text-gray-400" /> {t('businessPhone')}
</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="0987xxxxxx"
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
/>
</div>
{/* Email */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
<Mail className="w-3.5 h-3.5 text-gray-400" /> {t('businessEmail')}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="contact@business.com"
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
/>
</div>
</div>
{/* Địa chỉ */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-gray-400" /> {t('businessAddress')}
</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="VD: 123 Đường Trần Phú, Đà Lạt..."
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
/>
</div>
{/* Tọa độ địa lý */}
<div className="space-y-1.5 bg-blue-50/50 p-4 rounded-2xl border border-blue-100">
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-bold text-blue-700 uppercase tracking-wider flex items-center gap-1.5">
📍 Vị trí đa (Tùy chọn)
</span>
<button
type="button"
onClick={handleGetCurrentLocation}
className="text-xs font-bold text-blue-600 hover:text-blue-700 hover:underline flex items-center gap-1"
>
Lấy vị trí GPS hiện tại
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1"> đ (Latitude)</label>
<input
type="number"
step="any"
value={latitude}
onChange={(e) => setLatitude(e.target.value)}
placeholder="11.9404"
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-xs text-gray-800"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1">Kinh đ (Longitude)</label>
<input
type="number"
step="any"
value={longitude}
onChange={(e) => setLongitude(e.target.value)}
placeholder="108.4382"
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-xs text-gray-800"
/>
</div>
</div>
</div>
{/* Lý do */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">{t('reportReason')} *</label>
<textarea
required
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Hãy mô tả hành vi không đàng hoàng, lừa đảo hoặc gian dối của cơ sở/người dùng này..."
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all resize-none text-sm text-gray-800"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-4 bg-red-600 hover:bg-red-700 text-white font-bold rounded-2xl shadow-lg shadow-red-100 transition-all flex items-center justify-center gap-2 active:scale-[0.98] disabled:opacity-50"
>
{isLoading ? 'Đang gửi...' : t('submitReport')}
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : <ShieldAlert className="w-5 h-5" />}
</button>
</form>
)}
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
+31 -8
View File
@@ -1,24 +1,47 @@
const loadScript = (src: string): Promise<void> => {
const loadScript = (src: string, fallbackSrc?: string): Promise<void> => {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
if (
document.querySelector(`script[src="${src}"]`) ||
(fallbackSrc && document.querySelector(`script[src="${fallbackSrc}"]`))
) {
resolve();
return;
}
const script = document.createElement('script');
script.src = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script ${src}`));
script.onerror = () => {
if (fallbackSrc) {
console.warn(`Failed to load script ${src}. Trying fallback: ${fallbackSrc}`);
const fallbackScript = document.createElement('script');
fallbackScript.src = fallbackSrc;
fallbackScript.onload = () => resolve();
fallbackScript.onerror = () => reject(new Error(`Failed to load script ${fallbackSrc}`));
document.head.appendChild(fallbackScript);
} else {
reject(new Error(`Failed to load script ${src}`));
}
};
document.head.appendChild(script);
});
};
const loadModerationLibraries = async () => {
// Load TensorFlow first
await loadScript('https://cdn.jsdelivr.net/npm/@tensorflow/tfjs');
// Load models after tfjs is available
// Load TensorFlow first with fallback
await loadScript(
'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs',
'https://unpkg.com/@tensorflow/tfjs'
);
// Load models after tfjs is available, with fallbacks
await Promise.all([
loadScript('https://cdn.jsdelivr.net/npm/@tensorflow-models/blazeface'),
loadScript('https://cdn.jsdelivr.net/npm/nsfwjs@2.4.0/dist/bundle.js')
loadScript(
'https://cdn.jsdelivr.net/npm/@tensorflow-models/blazeface',
'https://unpkg.com/@tensorflow-models/blazeface'
),
loadScript(
'https://cdn.jsdelivr.net/npm/nsfwjs@2.4.0/dist/bundle.js',
'https://unpkg.com/nsfwjs@2.4.0/dist/bundle.js'
)
]);
};
+22 -1
View File
@@ -113,7 +113,28 @@ const translations: Record<string, Record<Language, string>> = {
viewMap: { vi: 'Bản đồ', en: 'Map', zh: '地图' },
viewTimeline: { vi: 'Lịch trình', en: 'Itinerary', zh: '行程表' },
sharedJourneyTitle: { vi: 'Hành trình chia sẻ khẩn cấp', en: 'Emergency Shared Journey', zh: '紧急分享行程' },
linkExpired: { vi: 'Liên kết không tồn tại hoặc đã bị vô hiệu hóa.', en: 'Link does not exist or has been disabled.', zh: '链接不存在或已被禁用。' }
linkExpired: { vi: 'Liên kết không tồn tại hoặc đã bị vô hiệu hóa.', en: 'Link does not exist or has been disabled.', zh: '链接不存在或已被禁用。' },
// Landing Page Buttons Short
shortExplore: { vi: 'Khám phá', en: 'Explore', zh: '探索' },
shortCamera: { vi: 'Chụp ảnh', en: 'Camera', zh: '拍照' },
reportBusinessBtn: { vi: 'Báo cáo sai phạm', en: 'Report Violation', zh: '举报' },
blacklistTitle: { vi: 'Widget Blacklist', en: 'Blacklist Widget', zh: '黑名单' },
reportModalTitle: { vi: 'Báo cáo cơ sở không đàng hoàng', en: 'Report Dishonest Business', zh: '举报不良商家' },
businessName: { vi: 'Tên cơ sở/người dùng', en: 'Name', zh: '名称' },
businessPhone: { vi: 'Số điện thoại', en: 'Phone Number', zh: '电话号码' },
businessEmail: { vi: 'Email liên hệ', en: 'Email Address', zh: '电子邮箱' },
businessAddress: { vi: 'Địa chỉ', en: 'Address', zh: '地址' },
businessType: { vi: 'Loại hình', en: 'Type', zh: '类型' },
typeUser: { vi: 'Người dùng', en: 'User', zh: '用户' },
typeRestaurant: { vi: 'Nhà hàng', en: 'Restaurant', zh: '餐厅' },
typeHotel: { vi: 'Khách sạn', en: 'Hotel', zh: '酒店' },
typeHomestay: { vi: 'Homestay', en: 'Homestay', zh: '民俗' },
reportReason: { vi: 'Lý do báo cáo', en: 'Reason for report', zh: '举报原因' },
submitReport: { vi: 'Gửi báo cáo', en: 'Submit Report', zh: '提交举报' },
reportSuccess: { vi: 'Gửi báo cáo thành công. Ban quản trị sẽ kiểm duyệt thông tin.', en: 'Report submitted successfully. The admin will review it.', zh: '提交成功。管理员将审核' },
emptyBlacklist: { vi: 'Chưa có cơ sở nào trong danh sách đen.', en: 'No businesses in blacklist yet.', zh: '黑名单中暂无商家。' },
tabReports: { vi: 'Blacklist', en: 'Blacklist', zh: '黑名单' }
};
export const useTranslation = () => {
+133
View File
@@ -24,4 +24,137 @@
max-width: 150px;
}
}
}
@keyframes bell-ring {
0% { transform: rotate(0); }
5% { transform: rotate(30deg); }
10% { transform: rotate(-28deg); }
15% { transform: rotate(34deg); }
20% { transform: rotate(-32deg); }
25% { transform: rotate(30deg); }
30% { transform: rotate(-28deg); }
35% { transform: rotate(26deg); }
40% { transform: rotate(-24deg); }
45% { transform: rotate(22deg); }
50% { transform: rotate(-20deg); }
55% { transform: rotate(18deg); }
60% { transform: rotate(-16deg); }
65% { transform: rotate(14deg); }
70% { transform: rotate(-12deg); }
75% { transform: rotate(10deg); }
80% { transform: rotate(-8deg); }
85% { transform: rotate(6deg); }
90% { transform: rotate(-4deg); }
95% { transform: rotate(2deg); }
100% { transform: rotate(0); }
}
.animate-ring {
display: inline-block !important;
transform-origin: top center !important;
transform-box: fill-box !important;
animation: bell-ring 1.5s ease-in-out infinite !important;
}
/* Light theme overrides for Member Dashboard */
html.light body,
html.light .app-container {
background-color: #f8fafc;
color: #334155;
}
html.light .bg-slate-950 {
background-color: #f8fafc !important;
color: #1e293b !important;
}
html.light .bg-slate-900 {
background-color: #f1f5f9 !important;
color: #1e293b !important;
}
html.light .bg-slate-900\/60 {
background-color: rgba(255, 255, 255, 0.7) !important;
border-color: #e2e8f0 !important;
}
html.light .bg-slate-900\/30 {
background-color: rgba(255, 255, 255, 0.4) !important;
border-color: #e2e8f0 !important;
}
html.light .bg-slate-950\/40 {
background-color: #f1f5f9 !important;
}
html.light .text-white,
html.light .text-white\/95,
html.light .text-slate-100 {
color: #0f172a !important;
}
html.light .text-slate-400,
html.light .text-slate-350,
html.light .text-slate-300 {
color: #475569 !important;
}
html.light .border-slate-800,
html.light .border-slate-800\/60,
html.light .border-slate-800\/80,
html.light .border-slate-900,
html.light .border-slate-700\/50 {
border-color: #cbd5e1 !important;
}
html.light .hover\:bg-slate-800\/50:hover {
background-color: rgba(226, 232, 240, 0.5) !important;
}
html.light .hover\:bg-slate-850:hover,
html.light .hover\:bg-slate-800:hover,
html.light .hover\:bg-white\/10:hover,
html.light .hover\:bg-white\/5:hover {
background-color: #e2e8f0 !important;
color: #0f172a !important;
}
/* Chat bubble styling overrides */
html.light .bg-slate-850\/50 {
background-color: rgba(241, 245, 249, 0.5) !important;
}
html.light .bg-slate-850 {
background-color: #f1f5f9 !important;
color: #0f172a !important;
}
html.light .bg-slate-800\/60 {
background-color: rgba(226, 232, 240, 0.6) !important;
}
html.light .bg-slate-800\/40 {
background-color: rgba(226, 232, 240, 0.4) !important;
}
html.light .bg-slate-950\/60 {
background-color: rgba(255, 255, 255, 0.8) !important;
}
html.light .bg-rose-950\/10 {
background-color: #fef2f2 !important;
border-color: #fee2e2 !important;
}
html.light .text-rose-400 {
color: #dc2626 !important;
}
html.light .bg-rose-950\/40 {
background-color: #fee2e2 !important;
}
html.light .border-rose-900\/50 {
border-color: #fecaca !important;
}
+602 -1
View File
@@ -5,8 +5,9 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTourStore } from '@/store/useTourStore';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Lock, Globe, Sun, Moon, Laptop, Users } from 'lucide-react';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Lock, Globe, Sun, Moon, Laptop, Users, ShieldAlert, Star } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
import { ReportBusinessModal } from '../components/ReportBusinessModal';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
import { CreateTourModal } from '../components/CreateTourModal';
@@ -227,6 +228,54 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
const [blacklist, setBlacklist] = useState<any[]>([]);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
const mapCenter = useTourStore(state => state.mapCenter);
// Recommendations and GPS States
const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]);
const [isRecommendedOpen, setIsRecommendedOpen] = useState(false);
const [isProposeModalOpen, setIsProposeModalOpen] = useState(false);
const [sortBlacklistByDistance, setSortBlacklistByDistance] = useState(false);
const [sortRecsByDistance, setSortRecsByDistance] = useState(false);
const [userGpsPos, setUserGpsPos] = useState<[number, number] | null>(null);
const [proposeForm, setProposeForm] = useState({
name: '',
type: 'RESTAURANT',
phone: '',
email: '',
address: '',
latitude: '',
longitude: '',
description: '',
stars: 5
});
const fetchBlacklist = async () => {
try {
const res = await fetch('/api/v1/reports/blacklist');
if (res.ok) {
const data = await res.json();
setBlacklist(data);
}
} catch (e) {
console.error('Lỗi khi tải blacklist:', e);
}
};
const fetchRecommendations = async () => {
try {
const res = await fetch('/api/v1/recommendations');
if (res.ok) {
const data = await res.json();
setRecommendedLocations(data);
}
} catch (e) {
console.error('Lỗi khi tải danh sách đề xuất:', e);
}
};
const fetchTrustedUsers = async () => {
try {
@@ -240,8 +289,121 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
}
};
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
const R = 6371; // Earth radius in km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
const requestGpsPosition = () => {
if (!navigator.geolocation) {
alert("Trình duyệt không hỗ trợ định vị GPS.");
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserGpsPos(posArray);
setUserPos(posArray);
setMapCenter(posArray);
},
() => {
alert("Không thể truy cập vị trí của bạn. Vui lòng cho phép quyền truy cập vị trí.");
}
);
};
const handleProposeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!proposeForm.name || !proposeForm.description) {
alert("Vui lòng điền tên địa điểm và mô tả.");
return;
}
const token = localStorage.getItem('token');
if (!token) {
alert("Bạn cần đăng nhập để gửi đề xuất địa điểm.");
return;
}
try {
const res = await fetch('/api/v1/recommendations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
...proposeForm,
latitude: parseFloat(proposeForm.latitude),
longitude: parseFloat(proposeForm.longitude)
})
});
if (res.ok) {
alert("Đề xuất của bạn đã được gửi thành công và đang chờ Admin phê duyệt!");
setIsProposeModalOpen(false);
setProposeForm({
name: '',
type: 'RESTAURANT',
phone: '',
email: '',
address: '',
latitude: userPos[0].toString(),
longitude: userPos[1].toString(),
description: '',
stars: 5
});
fetchRecommendations();
} else {
const errData = await res.json();
alert(errData.message || "Gửi đề xuất thất bại.");
}
} catch (err) {
alert("Đã xảy ra lỗi kết nối.");
}
};
const processedBlacklist = React.useMemo(() => {
if (!sortBlacklistByDistance || !userGpsPos) return blacklist;
return [...blacklist].sort((a, b) => {
if (typeof a.latitude !== 'number' || typeof a.longitude !== 'number') return 1;
if (typeof b.latitude !== 'number' || typeof b.longitude !== 'number') return -1;
const distA = calculateDistance(userGpsPos[0], userGpsPos[1], a.latitude, a.longitude);
const distB = calculateDistance(userGpsPos[0], userGpsPos[1], b.latitude, b.longitude);
return distA - distB;
});
}, [blacklist, sortBlacklistByDistance, userGpsPos]);
const processedRecommendations = React.useMemo(() => {
if (!sortRecsByDistance || !userGpsPos) return recommendedLocations;
return [...recommendedLocations].sort((a, b) => {
if (typeof a.latitude !== 'number' || typeof a.longitude !== 'number') return 1;
if (typeof b.latitude !== 'number' || typeof b.longitude !== 'number') return -1;
const distA = calculateDistance(userGpsPos[0], userGpsPos[1], a.latitude, a.longitude);
const distB = calculateDistance(userGpsPos[0], userGpsPos[1], b.latitude, b.longitude);
return distA - distB;
});
}, [recommendedLocations, sortRecsByDistance, userGpsPos]);
useEffect(() => {
if (isProposeModalOpen) {
setProposeForm(prev => ({
...prev,
latitude: userPos[0].toString(),
longitude: userPos[1].toString()
}));
}
}, [isProposeModalOpen, userPos]);
useEffect(() => {
fetchTrustedUsers();
fetchBlacklist();
fetchRecommendations();
}, []);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
@@ -565,6 +727,16 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</button>
)}
{/* Nút Báo cáo sai phạm */}
<button
onClick={() => setIsReportModalOpen(true)}
className="w-11 h-11 md:w-auto bg-red-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 cursor-pointer"
title={t('reportBusinessBtn') || 'Báo cáo sai phạm'}
>
<ShieldAlert className="w-5 h-5" />
<span className="hidden md:inline text-sm">{t('reportBusinessBtn') || 'Báo cáo'}</span>
</button>
{/* Lựa chọn Ngôn ngữ */}
<div className="relative group shrink-0">
<button className="w-11 h-11 bg-white dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 transition-all flex items-center justify-center border border-gray-100 dark:border-slate-700">
@@ -824,6 +996,88 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
/>
);
})}
{/* Render blacklist markers */}
{blacklist.map((item) => {
if (typeof item.latitude !== 'number' || typeof item.longitude !== 'number') return null;
return (
<Marker
key={`blacklist-${item.id}`}
position={[item.latitude, item.longitude]}
icon={L.divIcon({
html: `<div class="bg-red-600 text-white p-2 rounded-full shadow-lg border-2 border-white flex items-center justify-center animate-pulse"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-alert"><path d="M20 13c0 5-3.5 7.5-7.66 9.7a1 1 0 0 1-.68 0C7.5 20.5 4 18 4 13V6a1 1 0 0 1 .76-.97l8-2a1 1 0 0 1 .48 0l8 2A1 1 0 0 1 20 6z"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg></div>`,
className: 'custom-blacklist-marker',
iconSize: [32, 32],
iconAnchor: [16, 16],
})}
>
<Tooltip direction="top" offset={[0, -16]} opacity={1}>
<div className="p-2 max-w-[200px] text-left">
<div className="font-black text-red-600 text-xs mb-1 uppercase tracking-tight flex items-center gap-1">
Blacklist: {item.name}
</div>
<div className="text-[10px] bg-red-50 text-red-700 px-1.5 py-0.5 rounded font-black uppercase tracking-wider mb-1 w-max">
{item.type}
</div>
{item.address && (
<div className="text-[10px] text-gray-500 mb-1 font-semibold">{item.address}</div>
)}
{item.phone && (
<div className="text-[9px] text-gray-400">SĐT: {item.phone}</div>
)}
<div className="text-[10px] text-red-600 font-medium italic mt-1 pt-1 border-t border-red-100">
do: {item.reason}
</div>
</div>
</Tooltip>
</Marker>
);
})}
{/* Render recommended markers */}
{recommendedLocations.map((item) => {
if (typeof item.latitude !== 'number' || typeof item.longitude !== 'number') return null;
return (
<Marker
key={`recommendation-${item.id}`}
position={[item.latitude, item.longitude]}
icon={L.divIcon({
html: `<div class="bg-emerald-600 text-white p-2 rounded-full shadow-lg border-2 border-white flex items-center justify-center animate-pulse"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div>`,
className: 'custom-recommended-marker',
iconSize: [32, 32],
iconAnchor: [16, 16],
})}
>
<Tooltip direction="top" offset={[0, -16]} opacity={1}>
<div className="p-2.5 max-w-[220px] text-left">
<div className="font-black text-emerald-600 text-xs mb-1 uppercase tracking-tight flex items-center gap-1">
🌟 {item.name}
</div>
<div className="flex items-center gap-0.5 text-amber-500 mb-1">
{Array.from({ length: item.stars }).map((_, i) => (
<span key={i}></span>
))}
</div>
<div className="text-[10px] bg-emerald-50 text-emerald-700 px-1.5 py-0.5 rounded font-black uppercase tracking-wider mb-1 w-max">
{item.type === 'RESTAURANT' ? 'Nhà hàng' : item.type === 'HOTEL' ? 'Khách sạn' : 'Homestay'}
</div>
{item.address && (
<div className="text-[10px] text-gray-500 mb-1 font-semibold">{item.address}</div>
)}
{item.phone && (
<div className="text-[9px] text-gray-400">SĐT: {item.phone}</div>
)}
{item.email && (
<div className="text-[9px] text-gray-400">Email: {item.email}</div>
)}
<div className="text-[10px] text-slate-600 font-medium italic mt-1 pt-1 border-t border-emerald-100 whitespace-pre-line">
{item.description}
</div>
</div>
</Tooltip>
</Marker>
);
})}
</MapContainer>
{/* Context Menu Chia sẻ */}
@@ -965,6 +1219,353 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</div>
)}
</div>
{/* Floating Widgets Container (Bottom Right) */}
<div className="absolute bottom-6 right-6 z-[1002] pointer-events-auto flex flex-col items-end gap-2">
{/* Recommended Locations Toggle Button */}
<button
onClick={() => {
setIsRecommendedOpen(prev => !prev);
setIsBlacklistOpen(false);
}}
className="bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 text-slate-800 dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95 border-emerald-500/20"
>
<Star className="w-4 h-4 text-emerald-500 fill-emerald-500 animate-pulse" />
<span>{t('recommendedTitle') || 'Đề xuất dịch vụ'} ({recommendedLocations.length})</span>
</button>
{/* Blacklist Toggle Button */}
<button
onClick={() => {
setIsBlacklistOpen(prev => !prev);
setIsRecommendedOpen(false);
}}
className="bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 text-slate-800 dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95 border-red-500/20"
>
<ShieldAlert className="w-4 h-4 text-red-500 animate-pulse" />
<span>{t('blacklistTitle') || 'Danh sách đen'} ({blacklist.length})</span>
</button>
{/* Recommendations Panel */}
{isRecommendedOpen && (
<div className="bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-gray-100 dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
<h4 className="text-xs font-black uppercase text-emerald-600 dark:text-emerald-500 tracking-widest mb-3 flex items-center gap-2 border-b border-gray-100 dark:border-slate-800 pb-2">
🌟 {t('recommendedTitle') || 'Đề xuất chất lượng'}
</h4>
<div className="flex items-center justify-between gap-2 mb-2">
<button
onClick={() => {
if (!sortRecsByDistance && !userGpsPos) {
requestGpsPosition();
}
setSortRecsByDistance(prev => !prev);
}}
className={`text-[9px] px-2.5 py-1 rounded-xl font-bold transition-all cursor-pointer ${
sortRecsByDistance
? 'bg-emerald-600 text-white shadow-sm'
: 'bg-gray-100 hover:bg-gray-200 text-gray-600 dark:bg-slate-850 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
{sortRecsByDistance ? '✓ Đang lọc gần đây' : '🔍 Xem gần tôi'}
</button>
<button
onClick={() => setIsProposeModalOpen(true)}
className="text-[9px] px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold transition-all shadow-sm cursor-pointer"
>
+ Đ xuất đa điểm
</button>
</div>
{processedRecommendations.length === 0 ? (
<p className="text-xs text-gray-400 italic text-center py-4">Chưa đa điểm đ xuất nào.</p>
) : (
<div className="space-y-2.5">
{processedRecommendations.map((item) => {
const dist = userGpsPos && typeof item.latitude === 'number' && typeof item.longitude === 'number'
? calculateDistance(userGpsPos[0], userGpsPos[1], item.latitude, item.longitude)
: null;
return (
<div key={item.id} className="flex flex-col gap-1 p-2.5 bg-gray-50/50 dark:bg-slate-850/50 rounded-2xl border border-gray-100/50 dark:border-slate-800/50 text-left">
<div className="flex items-start justify-between gap-1.5">
<span className="text-xs font-bold text-gray-800 dark:text-slate-200 truncate">{item.name}</span>
<span className="text-[8px] bg-emerald-50 dark:bg-emerald-950/40 text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 rounded font-black shrink-0">
{item.type === 'RESTAURANT' ? 'Nhà hàng' : item.type === 'HOTEL' ? 'Khách sạn' : 'Homestay'}
</span>
</div>
<div className="flex items-center gap-0.5 text-amber-500 text-[10px]">
{Array.from({ length: item.stars || 5 }).map((_, i) => (
<span key={i}></span>
))}
</div>
{item.address && (
<div className="text-[10px] text-gray-500 dark:text-gray-400 truncate">{item.address}</div>
)}
{item.phone && (
<div className="text-[9px] text-gray-400">SĐT: {item.phone}</div>
)}
{dist !== null && (
<div className="text-[10px] text-emerald-600 dark:text-emerald-400 font-bold">
Cách đây: {dist < 1 ? `${Math.round(dist * 1000)} m` : `${dist.toFixed(1)} km`}
</div>
)}
{item.latitude && item.longitude && (
<button
onClick={() => {
setUserPos([item.latitude, item.longitude]);
setMapCenter([item.latitude, item.longitude]);
}}
className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0"
>
📍 Đnh vị trên bản đ
</button>
)}
<div className="text-[10px] text-slate-600 dark:text-slate-400 font-medium italic mt-1.5 pt-1.5 border-t border-gray-100 dark:border-slate-800 whitespace-pre-line">
{item.description}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* Blacklist Panel */}
{isBlacklistOpen && (
<div className="bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-gray-100 dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
<h4 className="text-xs font-black uppercase text-red-600 dark:text-red-500 tracking-widest mb-3 flex items-center gap-2 border-b border-gray-100 dark:border-slate-800 pb-2">
{t('blacklistTitle')}
</h4>
<div className="flex items-center justify-between gap-2 mb-2">
<button
onClick={() => {
if (!sortBlacklistByDistance && !userGpsPos) {
requestGpsPosition();
}
setSortBlacklistByDistance(prev => !prev);
}}
className={`text-[9px] px-2.5 py-1 rounded-xl font-bold transition-all cursor-pointer ${
sortBlacklistByDistance
? 'bg-red-600 text-white shadow-sm'
: 'bg-gray-100 hover:bg-gray-200 text-gray-600 dark:bg-slate-850 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
{sortBlacklistByDistance ? '✓ Đang lọc gần đây' : '🔍 Xem gần tôi'}
</button>
</div>
{processedBlacklist.length === 0 ? (
<p className="text-xs text-gray-400 italic text-center py-4">{t('emptyBlacklist')}</p>
) : (
<div className="space-y-2.5">
{processedBlacklist.map((item) => {
const dist = userGpsPos && typeof item.latitude === 'number' && typeof item.longitude === 'number'
? calculateDistance(userGpsPos[0], userGpsPos[1], item.latitude, item.longitude)
: null;
return (
<div key={item.id} className="flex flex-col gap-1 p-2.5 bg-gray-50/50 dark:bg-slate-850/50 rounded-2xl border border-gray-100/50 dark:border-slate-800/50 text-left">
<div className="flex items-start justify-between gap-1.5">
<span className="text-xs font-bold text-gray-800 dark:text-slate-200 truncate">{item.name}</span>
<span className="text-[8px] bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 px-1 py-0.5 rounded font-black shrink-0">
{item.type}
</span>
</div>
{item.address && (
<div className="text-[10px] text-gray-500 dark:text-gray-400 truncate">{item.address}</div>
)}
{item.phone && (
<div className="text-[9px] text-gray-400">SĐT: {item.phone}</div>
)}
{dist !== null && (
<div className="text-[10px] text-red-600 dark:text-red-400 font-bold">
Cách đây: {dist < 1 ? `${Math.round(dist * 1000)} m` : `${dist.toFixed(1)} km`}
</div>
)}
{item.latitude && item.longitude && (
<button
onClick={() => {
setUserPos([item.latitude, item.longitude]);
setMapCenter([item.latitude, item.longitude]);
}}
className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0"
>
📍 Đnh vị trên bản đ
</button>
)}
<div className="text-[10px] text-red-600 dark:text-red-400 font-medium italic mt-1.5 pt-1.5 border-t border-gray-100 dark:border-slate-850">
do: {item.reason}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{/* Propose Location Modal */}
{isProposeModalOpen && (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-950/60 backdrop-blur-sm" onClick={() => setIsProposeModalOpen(false)} />
<div className="relative w-full max-w-md bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 rounded-3xl shadow-2xl overflow-hidden flex flex-col p-6 animate-in zoom-in-95 duration-200">
<h3 className="text-base font-black uppercase text-emerald-600 dark:text-emerald-500 tracking-wider mb-4 flex items-center gap-1.5">
🌟 Đ xuất đa điểm chất lượng
</h3>
<form onSubmit={handleProposeSubmit} className="space-y-3.5 text-left text-gray-850 dark:text-slate-100 text-xs">
<div>
<label className="block text-[10px] uppercase font-black text-gray-450 mb-1">Tên đa điểm *</label>
<input
type="text"
required
placeholder="Ví dụ: Khách sạn Mường Thanh"
value={proposeForm.name}
onChange={(e) => setProposeForm(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Loại hình *</label>
<select
value={proposeForm.type}
onChange={(e) => setProposeForm(prev => ({ ...prev, type: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
>
<option value="RESTAURANT">Nhà hàng</option>
<option value="HOTEL">Khách sạn</option>
<option value="HOMESTAY">Homestay</option>
</select>
</div>
<div>
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Đánh giá sao *</label>
<select
value={proposeForm.stars}
onChange={(e) => setProposeForm(prev => ({ ...prev, stars: parseInt(e.target.value) || 5 }))}
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
>
<option value="5"> (5 sao)</option>
<option value="4"> (4 sao)</option>
<option value="3"> (3 sao)</option>
<option value="2"> (2 sao)</option>
<option value="1"> (1 sao)</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Số điện thoại</label>
<input
type="tel"
placeholder="SĐT liên hệ"
value={proposeForm.phone}
onChange={(e) => setProposeForm(prev => ({ ...prev, phone: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Email</label>
<input
type="email"
placeholder="Email liên hệ"
value={proposeForm.email}
onChange={(e) => setProposeForm(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
/>
</div>
</div>
<div>
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Đa chỉ</label>
<input
type="text"
placeholder="Địa chỉ cụ thể"
value={proposeForm.address}
onChange={(e) => setProposeForm(prev => ({ ...prev, address: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
/>
</div>
<div className="grid grid-cols-2 gap-3 bg-gray-50 dark:bg-slate-950/40 p-2.5 rounded-xl border border-gray-150 dark:border-slate-850">
<div>
<label className="block text-[9px] uppercase font-black text-gray-450 mb-0.5"> đ (Lat)</label>
<input
type="number"
step="any"
value={proposeForm.latitude}
onChange={(e) => setProposeForm(prev => ({ ...prev, latitude: e.target.value }))}
className="w-full px-2 py-1 border border-gray-200 dark:border-slate-800 rounded-lg focus:outline-none text-[11px] bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[9px] uppercase font-black text-gray-450 mb-0.5">Kinh đ (Lng)</label>
<input
type="number"
step="any"
value={proposeForm.longitude}
onChange={(e) => setProposeForm(prev => ({ ...prev, longitude: e.target.value }))}
className="w-full px-2 py-1 border border-gray-200 dark:border-slate-800 rounded-lg focus:outline-none text-[11px] bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100"
/>
</div>
</div>
<div>
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1"> tả / Review do đ xuất *</label>
<textarea
required
rows={3}
placeholder="Ví dụ: Thức ăn tươi ngon, không gian ấm cúng, phục vụ chu đáo..."
value={proposeForm.description}
onChange={(e) => setProposeForm(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setIsProposeModalOpen(false)}
className="flex-1 py-2.5 bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-750 text-gray-600 dark:text-slate-200 rounded-xl font-bold transition-all text-xs cursor-pointer"
>
Hủy bỏ
</button>
<button
type="submit"
className="flex-1 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold transition-all text-xs shadow-md active:scale-95 cursor-pointer"
>
Gửi đ xuất
</button>
</div>
</form>
</div>
</div>
)}
{/* Report Business Modal */}
<ReportBusinessModal
isOpen={isReportModalOpen}
onClose={() => {
setIsReportModalOpen(false);
fetchBlacklist();
}}
initialLatitude={mapCenter ? mapCenter[0] : undefined}
initialLongitude={mapCenter ? mapCenter[1] : undefined}
/>
</div>
);
};
+229 -126
View File
@@ -1,10 +1,12 @@
import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert } from 'lucide-react';
import { LoginModal } from '../components/LoginModal';
import { ReportBusinessModal } from '../components/ReportBusinessModal';
import { useNotification } from '@/hooks/useNotification';
import { processImageModeration } from '../hooks/useImageModeration';
import { useTranslation } from '../hooks/useTranslation';
import { useTheme } from '../hooks/useTheme';
import { compressImage } from '../utils/image';
interface LandingPageProps {
onContinue?: () => void;
@@ -14,33 +16,7 @@ interface LandingPageProps {
isInitialSetup?: boolean;
}
// Component Animation cho người lữ hành
const TravelerAnimation = ({ onClick }: { onClick?: () => void }) => {
return <button
onClick={onClick}
className="relative w-48 h-48 flex items-center justify-center transition-transform active:scale-95 focus:outline-none"
style={{
animation: 'gentle-shake 5s ease-in-out infinite',
}}
aria-label="Khám phá bản đồ"
>
{/* Giảm kích thước icon xuống 75% (w-24 -> w-18, md:w-28 -> md:w-21) */}
<Camera className="w-18 h-18 md:w-21 md:h-21 text-white/80 drop-shadow-2xl" strokeWidth={1.5} />
{/* Keyframes cho animation rung lắc */}
<style>{`
@keyframes gentle-shake {
0%, 100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(2deg) scale(1.05);
}
}
`}</style>
</button>;
};
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
@@ -49,6 +25,8 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
const [blacklist, setBlacklist] = useState<any[]>([]);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [currentBgIndex, setCurrentBgIndex] = useState(0);
const [bg1, setBg1] = useState('/background.avif');
const [bg2, setBg2] = useState('');
@@ -56,6 +34,39 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
const [fade2, setFade2] = useState(false);
const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
const [touchOffsetX, setTouchOffsetX] = useState(0);
const touchStartXRef = useRef(0);
const isSwipingRef = useRef(false);
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
const handleTouchStart = (e: React.TouchEvent) => {
touchStartXRef.current = e.touches[0].clientX;
isSwipingRef.current = true;
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isSwipingRef.current) return;
const currentX = e.touches[0].clientX;
const diffX = currentX - touchStartXRef.current;
// Clamp the translation to [-70, 70] to ensure the background never shows white/black gaps
const clampedX = Math.max(-70, Math.min(70, diffX));
setTouchOffsetX(clampedX);
};
const handleTouchEnd = () => {
if (!isSwipingRef.current) return;
isSwipingRef.current = false;
// Threshold lowered to 50px for better swipe responsiveness on mobile screens
if (touchOffsetX > 50 && publicPhotos.length > 0) {
setCurrentBgIndex((prev) => (prev - 1 + publicPhotos.length) % publicPhotos.length);
} else if (touchOffsetX < -50 && publicPhotos.length > 0) {
setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length);
}
setTouchOffsetX(0);
};
useEffect(() => {
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
if (!nextUrl) return;
@@ -105,9 +116,22 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
}
};
const fetchBlacklist = async () => {
try {
const res = await fetch('/api/v1/reports/blacklist');
if (res.ok) {
const data = await res.json();
setBlacklist(data);
}
} catch (e) {
console.error('Lỗi khi tải blacklist:', e);
}
};
useEffect(() => {
fetchPublicPhotos();
fetchTrustedUsers();
fetchBlacklist();
}, []);
useEffect(() => {
@@ -125,8 +149,10 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try {
// Nén ảnh trước
const compressedFile = await compressImage(file);
// 0. Kiểm duyệt ảnh
const moderationResult = await processImageModeration(file);
const moderationResult = await processImageModeration(compressedFile);
if (moderationResult.blocked) {
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
return;
@@ -228,43 +254,67 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
return (
<div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative">
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning */}
<div className="absolute inset-0 z-0 bg-gray-950 overflow-hidden">
{/* Slot 1 */}
{bg1 && (
<img
key={bg1}
src={bg1}
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade1 ? 0.8 : 0,
}}
alt="Travel Background 1"
/>
)}
{/* Slot 2 */}
{bg2 && (
<img
key={bg2}
src={bg2}
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade2 ? 0.8 : 0,
}}
alt="Travel Background 2"
/>
)}
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning & Swiping */}
<div
className="absolute inset-0 z-0 bg-gray-950 overflow-hidden"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
className="absolute inset-y-0 left-[-15%] right-[-15%] cursor-grab active:cursor-grabbing"
style={{
transform: `translateX(${touchOffsetX}px)`,
transition: touchOffsetX === 0 ? 'transform 0.3s ease-out' : 'none',
touchAction: 'none'
}}
>
{/* Slot 1 */}
{bg1 && (
<img
key={bg1}
src={bg1}
draggable="false"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
style={{
opacity: fade1 ? 0.8 : 0,
}}
alt="Travel Background 1"
/>
)}
{/* Slot 2 */}
{bg2 && (
<img
key={bg2}
src={bg2}
draggable="false"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
style={{
opacity: fade2 ? 0.8 : 0,
}}
alt="Travel Background 2"
/>
)}
</div>
</div>
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
<style>{`
@keyframes pan-left-to-right {
0% {
transform: translateX(0) translateZ(0);
transform: scale(1.08) translate(0, 0) translateZ(0);
}
100% {
transform: translateX(-16.667%) translateZ(0);
transform: scale(1.16) translate(-1%, 0.5%) translateZ(0);
}
}
.animate-panning {
@@ -274,18 +324,18 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
`}</style>
{/* Top Bar - Thanh điều hướng trên cùng */}
<div className="absolute top-0 left-0 right-0 z-20 p-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center bg-gradient-to-b from-slate-950/40 to-transparent">
<div className="flex items-center gap-2 text-white drop-shadow-lg">
<Compass className="w-8 h-8" />
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">YoTrip</span>
<div className="absolute top-0 left-0 right-0 z-20 p-3 sm:p-4 pt-[calc(0.75rem+env(safe-area-inset-top,0px))] sm:pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center bg-gradient-to-b from-slate-950/40 to-transparent">
<div className="flex items-center gap-1.5 sm:gap-2 text-white drop-shadow-lg">
<Compass className="w-7 h-7 sm:w-8 sm:h-8" />
<span className="text-lg sm:text-xl font-black tracking-tighter uppercase hidden sm:block">YoTrip</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 sm:gap-3">
{/* Language Selector */}
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-3 py-1.5 text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer"
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-2 py-1 sm:px-3 sm:py-1.5 text-[11px] sm:text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer max-w-[80px] sm:max-w-none"
>
<option value="vi" className="text-black">Tiếng Việt</option>
<option value="en" className="text-black">English</option>
@@ -296,7 +346,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-3 py-1.5 text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer"
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-2 py-1 sm:px-3 sm:py-1.5 text-[11px] sm:text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer max-w-[80px] sm:max-w-none"
>
<option value="light" className="text-black">{t('themeLight') || 'Sáng'}</option>
<option value="dark" className="text-black">{t('themeDark') || 'Tối'}</option>
@@ -304,11 +354,19 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
</select>
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-2 bg-white/15 backdrop-blur-md text-white font-bold py-2 px-4 rounded-full border border-white/25 hover:bg-white/25 transition-all shadow-md active:scale-95"
onClick={() => setIsReportModalOpen(true)}
className="flex items-center justify-center gap-1.5 bg-red-600/80 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-3.5 rounded-full border border-red-500/30 hover:bg-red-500 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer"
>
<LogIn className="w-4 h-4" />
<span className="text-sm">{t('login')}</span>
<ShieldAlert className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('reportBusinessBtn')}</span>
</button>
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-1 sm:gap-2 bg-white/15 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-4 rounded-full border border-white/25 hover:bg-white/25 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer"
>
<LogIn className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span>{t('login')}</span>
</button>
</div>
</div>
@@ -349,6 +407,38 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
</div>
</div>
{/* Floating Blacklist Panel (Right Side on Desktop) */}
<div className="absolute right-6 top-24 bottom-36 z-20 hidden md:flex flex-col w-72 bg-slate-900/60 backdrop-blur-md border border-white/10 rounded-[32px] p-5 text-white overflow-hidden shadow-2xl animate-in slide-in-from-right duration-500 animate-out duration-300">
<h3 className="text-base font-black flex items-center gap-2 mb-3 border-b border-white/10 pb-2 text-red-400">
<ShieldAlert className="w-5 h-5 text-red-500" /> {t('blacklistTitle')}
</h3>
<div className="flex-1 overflow-y-auto space-y-3 pr-1 no-scrollbar">
{blacklist.length === 0 ? (
<div className="text-xs text-white/50 italic py-4 text-center">{t('emptyBlacklist')}</div>
) : (
blacklist.map((item, idx) => (
<div key={idx} className="flex flex-col gap-1.5 bg-white/5 hover:bg-white/10 p-3 rounded-2xl border border-white/5 transition-all text-left">
<div className="flex items-start justify-between gap-2">
<div className="text-xs font-bold text-red-300 truncate flex-1">{item.name}</div>
<span className="text-[8px] bg-red-950/80 text-red-400 border border-red-900/50 px-1.5 py-0.5 rounded font-black shrink-0">
{item.type}
</span>
</div>
{item.address && (
<div className="text-[10px] text-white/60 truncate">{item.address}</div>
)}
{item.phone && (
<div className="text-[10px] text-white/40">SĐT: {item.phone}</div>
)}
<div className="text-[10px] text-red-400/90 italic bg-red-950/30 p-1.5 rounded-lg border border-red-950/40">
do: {item.reason}
</div>
</div>
))
)}
</div>
</div>
{/* Mobile Only: Trusted Members Top Bar */}
{trustedUsers.length > 0 && (
<div className="absolute top-24 left-4 right-4 z-20 md:hidden flex flex-col gap-1 bg-slate-950/45 backdrop-blur-sm p-2 rounded-2xl border border-white/5">
@@ -367,12 +457,6 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
</div>
)}
{/* Body: Animation người lữ hành */}
<div className="absolute inset-0 flex items-center justify-center z-10" >
{/* Kích hoạt click vào file input để chọn hoặc chụp ảnh */}
<TravelerAnimation onClick={() => fileInputRef.current?.click()} />
</div>
{/* Input chọn file ẩn để chụp/chọn ảnh */}
<input
type="file"
@@ -383,59 +467,72 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
className="hidden"
/>
{/* Community Gallery Previews */}
{publicPhotos.length > 0 && (
<div className="absolute bottom-[calc(7.5rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
{t('momentsTitle')} ({publicPhotos.length})
</span>
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 px-4 justify-center">
{publicPhotos.slice(0, 8).map((photo, index) => (
<button
key={photo.id}
onClick={() => setCurrentBgIndex(index)}
className={`relative w-14 h-14 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 shadow-inner bg-slate-800 ${
currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
}`}
>
<img src={photo.imageUrl} alt="Community thumbnail" className="w-full h-full object-cover" />
{/* Border Overlay absolute to prevent border clipping or corner overlap */}
<div className={`absolute inset-0 rounded-xl border-2 pointer-events-none transition-colors ${
currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
}`} />
</button>
))}
{/* Unified Bottom Container: Gallery thumbnails on top, action buttons below */}
<div className="absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4 flex flex-col items-center gap-4 max-w-md">
{/* Community Gallery Previews */}
{publicPhotos.length > 0 && (
<div className="w-full flex flex-col items-center gap-2">
<span className="text-[10px] font-black uppercase tracking-wider text-white/75 drop-shadow-md">
{t('momentsTitle')} ({publicPhotos.length})
</span>
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-1 px-4 justify-center">
{publicPhotos.slice(0, 8).map((photo, index) => (
<button
key={photo.id}
onClick={() => setCurrentBgIndex(index)}
className={`relative w-14 h-14 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 shadow-inner bg-slate-800 ${
currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
}`}
>
<img
src={photo.imageUrl}
alt="Community thumbnail"
draggable="false"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`w-full h-full object-cover ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
/>
{/* Border Overlay absolute to prevent border clipping or corner overlap */}
{/* Inset by 1.5px so it does not get clipped by parent overflow-hidden border */}
<div className={`absolute inset-[1.5px] rounded-[10px] border-2 pointer-events-none transition-colors ${
currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
}`} />
</button>
))}
</div>
</div>
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
)}
{/* Buttons */}
<div className="w-full flex gap-3 items-center justify-center">
<button
onClick={onGoToMap}
className="flex-1 flex items-center justify-center gap-2 bg-emerald-600/90 hover:bg-emerald-500 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-emerald-500/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
>
<MapIcon className="w-4.5 h-4.5" />
<span>{t('shortExplore') || 'Khám phá'}</span>
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="flex-1 flex items-center justify-center gap-2 bg-white/20 backdrop-blur-lg hover:bg-white/30 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-white/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
>
<Camera className="w-4.5 h-4.5" />
<span>{t('shortCamera') || 'Chụp ảnh'}</span>
</button>
</div>
)}
{/* Bottom Bar - Nút hành động chính */}
<div className="absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4 flex flex-col sm:flex-row gap-3 items-center justify-center max-w-lg">
<button
onClick={onGoToMap}
className="w-full flex items-center justify-center gap-3 bg-emerald-600/90 text-white font-bold py-3.5 px-6 rounded-2xl transition-all shadow-2xl border border-emerald-500/30 hover:bg-emerald-500 hover:scale-[1.02] active:scale-95 text-sm"
>
<MapIcon className="w-5 h-5" />
{t('exploreToursBtn')}
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-3.5 px-6 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 hover:scale-[1.02] active:scale-95 text-sm"
>
<Camera className="w-5 h-5" />
{t('quickCamera')}
</button>
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
{/* Login Modal Component */}
@@ -445,6 +542,12 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
onSwitchToSignup={onGoToSignup}
onLoginSuccess={onLoginSuccess}
/>
{/* Report Business Modal */}
<ReportBusinessModal
isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)}
/>
</div>
);
};
+282 -27
View File
@@ -21,12 +21,15 @@ import {
Download,
Loader2,
Bell,
BellOff
BellOff,
ShieldAlert
} from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
import { MyPhotosPage } from './MyPhotosPage';
import { useTranslation } from '../hooks/useTranslation';
import { useTheme } from '../hooks/useTheme';
interface MemberDashboardProps {
user: any;
@@ -44,6 +47,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
}) => {
const notify = useNotification();
const confirm = useConfirm();
const { t, lang, changeLanguage } = useTranslation();
const { theme, changeTheme } = useTheme();
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
@@ -75,12 +80,65 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
const [unreadTourSenders, setUnreadTourSenders] = useState<string[]>([]);
const [unreadTourChats, setUnreadTourChats] = useState<string[]>([]);
const hasNotifications = unreadChatSenders.length > 0 || unreadTourChats.length > 0 || unreadTourSenders.length > 0 || receivedRequests.length > 0;
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isLocating, setIsLocating] = useState(false);
// Emergency share states
const [sharingTour, setSharingTour] = useState<any | null>(null);
const [shareStatus, setShareStatus] = useState<any | null>(null);
const [loadingShare, setLoadingShare] = useState(false);
const handleOpenShareModal = async (tour: any) => {
setSharingTour(tour);
setShareStatus(null);
setLoadingShare(true);
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/tours/${tour.id}/share`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setShareStatus(data);
}
} catch (e) {
console.error('Error fetching share status:', e);
} finally {
setLoadingShare(false);
}
};
const handleToggleShare = async (isEnabled: boolean) => {
if (!sharingTour) return;
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/tours/${sharingTour.id}/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ isEnabled })
});
if (res.ok) {
const data = await res.json();
setShareStatus(data);
notify({
title: 'Thành công',
message: isEnabled ? 'Đã bật chia sẻ hành trình cứu hộ.' : 'Đã tắt chia sẻ.',
type: 'success'
});
}
} catch (e) {
console.error(e);
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
@@ -687,14 +745,18 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
type: 'info'
});
}}
className={`absolute -bottom-1 -right-1 p-1.5 rounded-full border shadow-md transition-all active:scale-90 z-20 ${
className={`absolute -bottom-1 -right-1 p-1.5 rounded-full border shadow-md transition-all active:scale-95 z-20 ${
muteNotifications
? 'bg-slate-900 border-slate-800 text-slate-400 hover:text-slate-200'
: 'bg-indigo-650 border-indigo-500 text-white hover:bg-indigo-600'
}`}
title={muteNotifications ? "Bật thông báo đẩy" : "Tắt thông báo đẩy"}
>
{muteNotifications ? <BellOff className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5 text-white" />}
{muteNotifications ? (
<BellOff className="w-3.5 h-3.5" />
) : (
<Bell className={`w-3.5 h-3.5 text-white ${hasNotifications ? 'animate-ring' : ''}`} />
)}
</button>
</div>
<h2 className="text-lg font-black tracking-tight text-white/95">
@@ -708,6 +770,34 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
)}
</div>
{/* Mobile Language & Theme Selectors */}
<div className="px-6 py-4 flex flex-col gap-3 border-b border-slate-900 bg-slate-900/10 shrink-0">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">{t('languageSelect') || 'Ngôn ngữ'}</span>
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2 text-xs font-bold focus:outline-none cursor-pointer"
>
<option value="vi">Tiếng Việt</option>
<option value="en">English</option>
<option value="zh"></option>
</select>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">{t('themeSelect') || 'Giao diện'}</span>
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2 text-xs font-bold focus:outline-none cursor-pointer"
>
<option value="light">{t('themeLight') || 'Sáng'}</option>
<option value="dark">{t('themeDark') || 'Tối'}</option>
<option value="system">{t('themeSystem') || 'Hệ thống'}</option>
</select>
</div>
</div>
{/* Explore Map Quick Button */}
<div className="p-4 border-b border-slate-900">
<button
@@ -919,7 +1009,11 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
}`}
title={muteNotifications ? "Bật thông báo đẩy" : "Tắt thông báo đẩy"}
>
{muteNotifications ? <BellOff className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5 text-white" />}
{muteNotifications ? (
<BellOff className="w-3.5 h-3.5" />
) : (
<Bell className={`w-3.5 h-3.5 text-white ${hasNotifications ? 'animate-ring' : ''}`} />
)}
</button>
</div>
<h2 className="text-base font-black text-white">{user?.name || 'Thành viên'}</h2>
@@ -931,6 +1025,34 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
)}
</div>
{/* Desktop Language & Theme Selectors */}
<div className="px-6 py-4 flex flex-col gap-3 border-b border-slate-800/60 shrink-0">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-slate-500">{t('languageSelect') || 'Ngôn ngữ'}</span>
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="bg-slate-950 border border-slate-800 text-white rounded-xl px-2.5 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
>
<option value="vi">Tiếng Việt</option>
<option value="en">English</option>
<option value="zh"></option>
</select>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-slate-500">{t('themeSelect') || 'Giao diện'}</span>
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="bg-slate-950 border border-slate-800 text-white rounded-xl px-2.5 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
>
<option value="light">{t('themeLight') || 'Sáng'}</option>
<option value="dark">{t('themeDark') || 'Tối'}</option>
<option value="system">{t('themeSystem') || 'Hệ thống'}</option>
</select>
</div>
</div>
{/* Quick Nav Links */}
<div className="px-4 py-6 flex flex-col gap-2 border-b border-slate-800/60">
<button
@@ -1156,31 +1278,41 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
{tour.endDate ? new Date(tour.endDate).toLocaleDateString('vi-VN') : 'N/A'}
</span>
</div>
<div className="flex items-center justify-between gap-2 mt-2">
<div className="flex flex-col gap-2 mt-2">
<div className="flex gap-2">
<button
onClick={() => onViewTour(tour.id, 'dashboard')}
className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-350 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1"
>
<span>Xem chi tiết</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
localStorage.setItem('tour_detail_default_tab', 'chat');
setUnreadTourChats(prev => prev.filter(id => id !== tour.id));
onViewTour(tour.id, 'dashboard');
}}
className="flex-1 py-2 px-2 bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-indigo-500/30 hover:border-indigo-500 flex items-center justify-center gap-1 relative whitespace-nowrap"
>
<MessageSquare className="w-3.5 h-3.5" />
<span>Trò chuyện</span>
{unreadTourChats.includes(tour.id) && (
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full animate-ping border border-slate-800" />
)}
{unreadTourChats.includes(tour.id) && (
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full border border-slate-800" />
)}
</button>
</div>
<button
onClick={() => onViewTour(tour.id, 'dashboard')}
className="flex-1 py-2 px-3 bg-slate-900 hover:bg-slate-800 text-slate-350 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1.5"
onClick={() => handleOpenShareModal(tour)}
className="w-full py-2 px-3 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20 hover:border-rose-500 flex items-center justify-center gap-1.5"
>
<span>Xem chi tiết</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
localStorage.setItem('tour_detail_default_tab', 'chat');
setUnreadTourChats(prev => prev.filter(id => id !== tour.id));
onViewTour(tour.id, 'dashboard');
}}
className="flex-1 py-2 px-3 bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-indigo-500/30 hover:border-indigo-500 flex items-center justify-center gap-1.5 relative"
>
<MessageSquare className="w-3.5 h-3.5" />
<span>Trò chuyện</span>
{unreadTourChats.includes(tour.id) && (
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full animate-ping border border-slate-800" />
)}
{unreadTourChats.includes(tour.id) && (
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full border border-slate-800" />
)}
<ShieldAlert className="w-3.5 h-3.5 text-rose-500" />
<span>Chia sẻ khẩn cấp</span>
</button>
</div>
</div>
@@ -1805,6 +1937,129 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
</div>
)}
{/* Emergency Share Configuration Modal */}
{sharingTour && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300">
<div className="w-full max-w-lg bg-slate-900 border border-slate-800 rounded-[32px] overflow-hidden shadow-2xl animate-in zoom-in-95 duration-300">
{/* Modal Header */}
<div className="p-6 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-3 text-rose-500">
<ShieldAlert className="w-6 h-6 animate-pulse" />
<h3 className="text-lg font-black uppercase tracking-tight text-white">{t('emergencyShare')}</h3>
</div>
<button
onClick={() => setSharingTour(null)}
className="p-2 hover:bg-slate-800 rounded-xl text-slate-400 hover:text-white transition-all active:scale-95"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */}
<div className="p-6 space-y-6">
<div>
<h4 className="text-sm font-bold text-white mb-1">{sharingTour.title}</h4>
<p className="text-xs text-slate-450 leading-relaxed">{t('emergencyShareTooltip')}</p>
</div>
{loadingShare ? (
<div className="py-8 flex flex-col items-center justify-center gap-2">
<Loader2 className="w-8 h-8 text-rose-500 animate-spin" />
<span className="text-xs text-slate-500 font-bold">{t('loading')}</span>
</div>
) : (
<>
{/* Share Activation Toggle */}
<div className="bg-slate-950/40 border border-slate-800/60 p-4 rounded-2xl flex items-center justify-between">
<div>
<span className="text-xs font-bold text-slate-300">Kích hoạt đưng dẫn cứu hộ</span>
</div>
{shareStatus && (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={shareStatus.isEnabled}
onChange={(e) => handleToggleShare(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-rose-600"></div>
</label>
)}
</div>
{shareStatus?.isEnabled && (
<>
{/* Configuration: Language and Theme selectors */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase tracking-widest text-slate-500">{t('languageSelect')}</label>
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2.5 text-xs font-bold focus:outline-none cursor-pointer"
>
<option value="vi">Tiếng Việt</option>
<option value="en">English</option>
<option value="zh"></option>
</select>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase tracking-widest text-slate-500">{t('themeSelect')}</label>
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2.5 text-xs font-bold focus:outline-none cursor-pointer"
>
<option value="light">{t('themeLight')}</option>
<option value="dark">{t('themeDark')}</option>
<option value="system">{t('themeSystem')}</option>
</select>
</div>
</div>
{/* Shareable Link Input with Copy button */}
<div className="bg-rose-950/10 border border-rose-950/20 p-4 rounded-2xl space-y-2">
<div className="text-[10px] font-black text-rose-400 uppercase tracking-widest">Đưng dẫn khẩn cấp:</div>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${window.location.origin}/journey/${shareStatus.token}?lang=${lang}&theme=${theme}`}
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 text-xs text-slate-200 select-all outline-none"
/>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/journey/${shareStatus.token}?lang=${lang}&theme=${theme}`);
notify({ title: 'Thành công', message: 'Đã sao chép liên kết!', type: 'success' });
}}
className="bg-rose-600 hover:bg-rose-700 text-white font-bold px-4 py-2.5 rounded-xl text-xs transition-all active:scale-95 shrink-0"
>
{t('copyShareLink')}
</button>
</div>
</div>
</>
)}
</>
)}
</div>
{/* Modal Footer */}
<div className="p-6 bg-slate-950/20 border-t border-slate-800/80 flex justify-end">
<button
type="button"
onClick={() => setSharingTour(null)}
className="py-2.5 px-6 bg-slate-800 hover:bg-slate-700 text-white font-bold rounded-xl text-xs transition-all active:scale-95"
>
Đóng
</button>
</div>
</div>
</div>
)}
</div>
</main>
</div>
+268 -36
View File
@@ -1,10 +1,10 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { ChevronLeft, FileText, Plus, Search, Trash2, Loader2, Save, Calendar } from 'lucide-react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { ChevronLeft, FileText, Plus, Search, Trash2, Loader2, Save, Calendar, Edit } from 'lucide-react';
import ReactQuill, { Quill } from 'react-quill-new'; // Nếu react-quill gặp lỗi với React 18, hãy dùng react-quill-new
import 'react-quill-new/dist/quill.snow.css';
// Cấu hình Lucide Icons cho Quill
const Icons = Quill.import('ui/icons');
const Icons = Quill.import('ui/icons') as any;
Icons['bold'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>';
Icons['italic'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>';
Icons['underline'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>';
@@ -29,36 +29,171 @@ interface Note {
title: string;
content: string;
createdAt: string;
synced?: boolean;
deletedLocally?: boolean;
}
export const MyNotePage = ({ onBack }: { onBack: () => void }) => {
export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => void }) => {
const [isLoading, setIsLoading] = useState(false);
const [notes, setNotes] = useState<Note[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
const quillRef = useRef<ReactQuill>(null);
// State cho form ghi chú
const [noteForm, setNoteForm] = useState({ title: '', content: '' });
const syncLocalNotesToServer = async (localNotes: Note[]) => {
const updatedNotesList = [...localNotes];
let hasChanges = false;
// 1. Đồng bộ các ghi chú bị xóa offline
const deletedNotes = localNotes.filter((n: any) => n.deletedLocally);
for (const note of deletedNotes) {
try {
const res = await fetch(`/api/v1/tours/${tourId}/notes/${note.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (res.ok || res.status === 404) {
const idx = updatedNotesList.findIndex(n => n.id === note.id);
if (idx !== -1) {
updatedNotesList.splice(idx, 1);
hasChanges = true;
}
}
} catch (err) {
console.error("Lỗi đồng bộ xóa ghi chú:", err);
}
}
// 2. Đồng bộ các ghi chú tạo mới offline
const tempNotes = localNotes.filter(n => n.id.startsWith('temp_') && !(n as any).deletedLocally);
for (const note of tempNotes) {
try {
const res = await fetch(`/api/v1/tours/${tourId}/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
title: note.title,
content: note.content
})
});
if (res.ok) {
const savedNote = await res.json();
const idx = updatedNotesList.findIndex(n => n.id === note.id);
if (idx !== -1) {
updatedNotesList[idx] = {
...savedNote,
synced: true
};
hasChanges = true;
}
}
} catch (err) {
console.error("Lỗi đồng bộ tạo ghi chú mới:", err);
}
}
// 3. Đồng bộ các ghi chú chỉnh sửa offline
const editedNotes = localNotes.filter((n: any) => !n.id.startsWith('temp_') && n.synced === false && !n.deletedLocally);
for (const note of editedNotes) {
try {
const res = await fetch(`/api/v1/tours/${tourId}/notes/${note.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
title: note.title,
content: note.content
})
});
if (res.ok) {
const updatedNote = await res.json();
const idx = updatedNotesList.findIndex(n => n.id === note.id);
if (idx !== -1) {
updatedNotesList[idx] = {
...updatedNote,
synced: true
};
hasChanges = true;
}
}
} catch (err) {
console.error("Lỗi đồng bộ cập nhật ghi chú:", err);
}
}
return { updatedNotesList, hasChanges };
};
const fetchServerNotes = async (currentLocalNotes: Note[]) => {
setIsLoading(true);
try {
const { updatedNotesList, hasChanges } = await syncLocalNotesToServer(currentLocalNotes);
let mergedNotes = updatedNotesList;
const res = await fetch(`/api/v1/tours/${tourId}/notes`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (res.ok) {
const serverNotes = await res.json();
if (Array.isArray(serverNotes)) {
const unsyncedNotes = mergedNotes.filter((n: any) => n.synced === false || n.id.startsWith('temp_') || n.deletedLocally);
const serverNotesFiltered = serverNotes.filter(sn => !unsyncedNotes.some(un => un.id === sn.id));
const finalNotes = [...unsyncedNotes, ...serverNotesFiltered].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setNotes(finalNotes);
localStorage.setItem(`my_journey_notes_${tourId}`, JSON.stringify(finalNotes));
setIsLoading(false);
return;
}
}
if (hasChanges) {
setNotes(mergedNotes);
localStorage.setItem(`my_journey_notes_${tourId}`, JSON.stringify(mergedNotes));
}
} catch (e) {
console.warn("Offline or backend fetch failed, using local storage cache:", e);
} finally {
setIsLoading(false);
}
};
// Khôi phục ghi chú từ localStorage khi load trang
useEffect(() => {
const saved = localStorage.getItem('my_journey_notes');
let initialNotes: Note[] = [];
const saved = localStorage.getItem(`my_journey_notes_${tourId}`);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
initialNotes = parsed;
setNotes(parsed);
}
} catch (e) { console.error("Lỗi parse notes:", e); }
}
}, []);
fetchServerNotes(initialNotes);
}, [tourId]);
// Tự động lưu ghi chú vào localStorage khi có thay đổi
useEffect(() => {
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
}, [notes]);
localStorage.setItem(`my_journey_notes_${tourId}`, JSON.stringify(notes));
}, [notes, tourId]);
// Cấu hình các công cụ định dạng cho ReactQuill
const quillModules = useMemo(() => ({
@@ -87,34 +222,121 @@ export const MyNotePage = ({ onBack }: { onBack: () => void }) => {
},
}), []);
const handleSaveNote = () => {
const handleSaveNote = async () => {
if (!noteForm.title.trim() && !noteForm.content.trim()) return;
const note: Note = {
id: Date.now().toString(),
title: noteForm.title || 'Ghi chú không tiêu đề',
content: noteForm.content,
createdAt: new Date().toISOString(),
};
setNotes([note, ...notes]);
setIsCreating(false);
setNoteForm({ title: '', content: '' });
};
if (editingNoteId) {
const updatedNotes = notes.map(n => n.id === editingNoteId ? {
...n,
title: noteForm.title || 'Ghi chú không tiêu đề',
content: noteForm.content,
createdAt: new Date().toISOString(),
synced: false
} : n);
setNotes(updatedNotes);
setIsCreating(false);
const targetId = editingNoteId;
setEditingNoteId(null);
setNoteForm({ title: '', content: '' });
const handleDeleteNote = (id: string) => {
if (window.confirm("Bạn có chắc chắn muốn xóa ghi chú này?")) {
setNotes(prev => prev.filter(n => n.id !== id));
// Sync to backend
try {
const res = await fetch(`/api/v1/tours/${tourId}/notes/${targetId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
title: noteForm.title || 'Ghi chú không tiêu đề',
content: noteForm.content
})
});
if (res.ok) {
const updatedNote = await res.json();
setNotes(prev => prev.map(n => n.id === targetId ? { ...updatedNote, synced: true } : n));
}
} catch (err) {
console.error("Lỗi đồng bộ cập nhật ghi chú:", err);
}
} else {
const tempId = `temp_${Date.now()}`;
const note: Note = {
id: tempId,
title: noteForm.title || 'Ghi chú không tiêu đề',
content: noteForm.content,
createdAt: new Date().toISOString(),
synced: false
};
setNotes([note, ...notes]);
setIsCreating(false);
setNoteForm({ title: '', content: '' });
// Sync to backend
try {
const res = await fetch(`/api/v1/tours/${tourId}/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
title: note.title,
content: note.content
})
});
if (res.ok) {
const savedNote = await res.json();
setNotes(prev => prev.map(n => n.id === tempId ? { ...savedNote, synced: true } : n));
}
} catch (err) {
console.error("Lỗi đồng bộ ghi chú mới:", err);
}
}
};
const filteredNotes = (notes || []).filter(n => {
const title = (n.title || '').toLowerCase();
const content = (n.content || '').replace(/<[^>]*>/g, '').toLowerCase();
const query = searchQuery.toLowerCase();
return title.includes(query) || content.includes(query);
});
const handleEditNote = (note: Note) => {
setNoteForm({ title: note.title, content: note.content });
setEditingNoteId(note.id);
setIsCreating(true);
};
const handleDeleteNote = async (id: string) => {
if (window.confirm("Bạn có chắc chắn muốn xóa ghi chú này?")) {
if (id.startsWith('temp_')) {
setNotes(prev => prev.filter(n => n.id !== id));
return;
}
setNotes(prev => prev.map(n => n.id === id ? { ...n, deletedLocally: true } : n));
try {
const res = await fetch(`/api/v1/tours/${tourId}/notes/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (res.ok || res.status === 404) {
setNotes(prev => prev.filter(n => n.id !== id));
}
} catch (err) {
console.error("Lỗi đồng bộ xóa ghi chú:", err);
}
}
};
const filteredNotes = (notes || [])
.filter((n: any) => !n.deletedLocally)
.filter(n => {
const title = (n.title || '').toLowerCase();
const content = (n.content || '').replace(/<[^>]*>/g, '').toLowerCase();
const query = searchQuery.toLowerCase();
return title.includes(query) || content.includes(query);
});
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
@@ -203,12 +425,22 @@ export const MyNotePage = ({ onBack }: { onBack: () => void }) => {
{new Date(note.createdAt).toLocaleDateString('vi-VN')}
</div>
</div>
<button
onClick={() => handleDeleteNote(note.id)}
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="flex gap-1">
<button
onClick={() => handleEditNote(note)}
className="p-2 text-gray-300 hover:text-blue-500 hover:bg-blue-50 rounded-xl transition-all opacity-0 group-hover:opacity-100"
title="Sửa ghi chú"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteNote(note.id)}
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all opacity-0 group-hover:opacity-100"
title="Xóa ghi chú"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div
className="text-sm text-gray-600 line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_td]:border [&_td]:border-gray-200 [&_td]:p-2 [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify"
+13
View File
@@ -107,6 +107,19 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
const [fitBoundsTrigger, setFitBoundsTrigger] = useState(0);
const [activeTab, setActiveTab] = useState<'map' | 'itinerary'>('map');
useEffect(() => {
const queryParams = new URLSearchParams(window.location.search);
const urlLang = queryParams.get('lang');
const urlTheme = queryParams.get('theme');
if (urlLang === 'vi' || urlLang === 'en' || urlLang === 'zh') {
changeLanguage(urlLang as any);
}
if (urlTheme === 'light' || urlTheme === 'dark' || urlTheme === 'system') {
changeTheme(urlTheme as any);
}
}, []);
// Viewer's own GPS tracking
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
const [isLocating, setIsLocating] = useState(false);
+151 -78
View File
@@ -1,5 +1,8 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { io } from 'socket.io-client';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import { robotoBase64 } from '../utils/pdfFont';
import { ItineraryTimeline } from '../components/ItineraryTimeline';
import { ExpenseManager } from '../components/ExpenseManager';
import { useTourStore } from '@/store/useTourStore';
@@ -20,7 +23,6 @@ import {
Map as MapIcon,
Wallet,
Image as ImageIcon,
Upload,
Calendar,
Users,
ChevronLeft,
@@ -359,7 +361,6 @@ export const TourDetailPage = ({
const confirm = useConfirm();
const currentTour = useTourStore(state => state.currentTour);
const legs = useTourStore(state => state.legs);
const publicTours = useTourStore(state => state.publicTours);
const userRole = useTourStore(state => state.userRole);
const mapCenter = useTourStore(state => state.mapCenter);
const [userLocation, setUserLocation] = useState<[number, number] | null>(null); // Vị trí hiện tại của người dùng
@@ -483,83 +484,153 @@ export const TourDetailPage = ({
};
const handleExportPDF = async () => {
if (!(window as any).html2pdf) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js';
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load html2pdf'));
document.head.appendChild(script);
});
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
// 1. Thêm font vào Virtual File System của jsPDF để hỗ trợ tiếng Việt
doc.addFileToVFS("Roboto-Regular.ttf", robotoBase64);
doc.addFont("Roboto-Regular.ttf", "Roboto", "normal");
doc.setFont("Roboto");
// 2. Vẽ Tiêu đề & Thông tin Tour ở đầu trang
const titleText = `LỊCH TRÌNH TOUR: ${currentTour?.title?.toUpperCase() || 'HÀNH TRÌNH TOUR'}`;
doc.setFontSize(16);
doc.text(titleText, 148.5, 15, { align: 'center' });
let startY = 22;
if (currentTour?.description) {
doc.setFontSize(10);
doc.text(currentTour.description, 148.5, startY, { align: 'center' });
startY += 6;
}
const style = document.createElement('style');
style.innerHTML = `
.pdf-exclude { display: none !important; }
.pdf-container { padding: 40px !important; color: #000 !important; background: #fff !important; }
.pdf-title { font-size: 24px !important; font-weight: bold !important; margin-bottom: 20px !important; text-align: center !important; }
.pdf-timeline { margin-top: 20px; }
.pdf-location-card { border: 1px solid #e5e7eb; padding: 15px; border-radius: 12px; margin-bottom: 15px; background: #fafafa; }
.pdf-leg-header { font-size: 16px; font-weight: bold; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #3b82f6; padding-bottom: 5px; }
`;
document.head.appendChild(style);
const dateRangeStr = `Thời gian: ${currentTour?.startDate ? new Date(currentTour.startDate).toLocaleDateString('vi-VN') : ''} - ${currentTour?.endDate ? new Date(currentTour.endDate).toLocaleDateString('vi-VN') : ''}`;
doc.setFontSize(10);
doc.text(dateRangeStr, 148.5, startY, { align: 'center' });
startY += 8;
const element = document.createElement('div');
element.className = 'pdf-container font-sans text-black bg-white';
const titleEl = document.createElement('h1');
titleEl.className = 'pdf-title';
titleEl.innerText = `Hành Trình: ${currentTour?.title || 'Tour Itinerary'}`;
element.appendChild(titleEl);
// 3. Chuẩn bị dữ liệu bảng
const tableColumn = ["STT", "Ngày giờ", "Chặng", "Địa điểm", "Ghi chú"];
const tableRows: any[] = [];
const subEl = document.createElement('div');
subEl.style.textAlign = 'center';
subEl.style.marginBottom = '30px';
subEl.style.fontSize = '12px';
subEl.style.color = '#555';
subEl.innerText = `Thời gian: ${currentTour?.startDate ? new Date(currentTour.startDate).toLocaleDateString('vi-VN') : ''} - ${currentTour?.endDate ? new Date(currentTour.endDate).toLocaleDateString('vi-VN') : ''}`;
element.appendChild(subEl);
const printDom = document.getElementById('itinerary-timeline-print-zone');
if (printDom) {
const clone = printDom.cloneNode(true) as HTMLElement;
// Clean clone layout by removing action buttons and interactive inputs, expenses
clone.querySelectorAll('button, input, textarea, .pdf-exclude, .expense-badge, .paid-by-badge, .comment-section, .location-actions, .mt-2.text-indigo-650, .flex.gap-2.mt-2').forEach(el => {
el.remove();
});
// Clear styles or apply simple standard styles so PDF generation is clean
clone.style.background = 'white';
clone.style.color = 'black';
element.appendChild(clone);
} else {
notify({ title: 'Lỗi', message: 'Không tìm thấy vùng hiển thị lịch trình để xuất PDF.', type: 'error' });
document.head.removeChild(style);
return;
}
const opt = {
margin: 10,
filename: `Lich_trinh_${currentTour?.title || 'tour'}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return '';
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${hours}:${minutes} ${day}/${month}/${year}`;
};
let stt = 1;
if (currentTour?.legs && currentTour.legs.length > 0) {
currentTour.legs.forEach((leg: any, legIdx: number) => {
const legName = leg.note || `Chặng ${legIdx + 1}`;
if (leg.locations && leg.locations.length > 0) {
leg.locations.forEach((loc: any) => {
const currentStt = stt++;
const arrivalStr = loc.arrivalTime ? `Đến: ${formatDateTime(loc.arrivalTime)}` : '';
const departureStr = loc.departureTime ? `Đi: ${formatDateTime(loc.departureTime)}` : '';
const timeText = [arrivalStr, departureStr].filter(Boolean).join('\n');
const locName = loc.name;
const addressStr = loc.address ? `\n📍 Địa chỉ: ${loc.address}` : '';
const locationText = `${locName}${addressStr}`;
const noteText = loc.notes || '';
tableRows.push([
currentStt,
timeText,
legName,
locationText,
noteText
]);
});
}
});
}
if (tableRows.length === 0) {
tableRows.push(["-", "-", "-", "Chưa có chặng hoặc địa điểm nào trong hành trình.", "-"]);
}
// 4. Vẽ bảng dùng autoTable
autoTable(doc, {
head: [tableColumn],
body: tableRows,
startY: startY,
theme: 'grid',
headStyles: { fillColor: [37, 99, 235], textColor: [255, 255, 255], fontStyle: 'normal' },
styles: { font: "Roboto", fontSize: 9, cellPadding: 3, overflow: 'linebreak' },
columnStyles: {
0: { cellWidth: 12, halign: 'center' }, // STT
1: { cellWidth: 50 }, // Ngày giờ
2: { cellWidth: 45 }, // Chặng
3: { cellWidth: 90 }, // Địa điểm
4: { cellWidth: 70 } // Ghi chú
},
margin: { left: 15, right: 15 }
});
try {
notify({ title: 'Đang tạo PDF...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
await (window as any).html2pdf().from(element).set(opt).save();
doc.save(`Lich_trinh_${currentTour?.title || 'tour'}.pdf`);
notify({ title: 'Thành công', message: 'Lịch trình đã được xuất ra tập tin PDF thành công.', type: 'success' });
} catch (err) {
console.error(err);
notify({ title: 'Lỗi', message: 'Không thể xuất PDF lịch trình.', type: 'error' });
} finally {
document.head.removeChild(style);
}
};
const handleExportCSV = () => {
if (!currentTour) return;
// CSV Header với UTF-8 BOM để Excel hiển thị đúng dấu tiếng Việt
let csvContent = '\uFEFF';
csvContent += 'Chặng,Địa điểm,Địa chỉ,Thời gian đến,Thời gian đi,Ghi chú\n';
if (currentTour.legs && currentTour.legs.length > 0) {
currentTour.legs.forEach((leg: any, legIdx: number) => {
const legTitle = leg.note || `Chặng ${legIdx + 1}`;
if (leg.locations && leg.locations.length > 0) {
leg.locations.forEach((loc: any) => {
const row = [
legTitle,
loc.name || '',
loc.address || '',
loc.arrivalTime ? new Date(loc.arrivalTime).toLocaleString('vi-VN') : '',
loc.departureTime ? new Date(loc.departureTime).toLocaleString('vi-VN') : '',
loc.notes || ''
].map(val => `"${val.replace(/"/g, '""')}"`).join(',');
csvContent += row + '\n';
});
} else {
csvContent += `"${legTitle.replace(/"/g, '""')}","Chưa có địa điểm",,,,\n`;
}
});
}
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `Lich_trinh_${currentTour.title || 'tour'}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
notify({
title: 'Thành công',
message: 'Đã xuất lịch trình ra tệp CSV (Google Sheets) thành công.',
type: 'success'
});
};
useEffect(() => {
fetchShareStatus();
}, [tourId]);
@@ -887,7 +958,7 @@ export const TourDetailPage = ({
useEffect(() => {
if (filteredPhotos.length > 0 && !selectedPhotoForDisplay) {
setSelectedPhotoForDisplay(getMostLikedPhoto(filteredPhotos));
} else if (selectedPhotoForDisplay && !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id)) {
} else if (selectedPhotoForDisplay && !filteredPhotos.some((p: any) => p.id === selectedPhotoForDisplay.id)) {
setSelectedPhotoForDisplay(filteredPhotos.length > 0 ? getMostLikedPhoto(filteredPhotos) : null);
}
}, [filteredPhotos, selectedPhotoForDisplay]);
@@ -982,9 +1053,9 @@ export const TourDetailPage = ({
// Hàm tối ưu để cập nhật số lượng bình luận mà không cần fetch lại toàn bộ Tour
const handleCommentIncrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
const updatedLegs = currentLegs.map((leg: any) => ({
...leg,
locations: leg.locations.map(loc =>
locations: leg.locations.map((loc: any) =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
: loc
@@ -996,9 +1067,9 @@ export const TourDetailPage = ({
const handleCommentDecrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
const updatedLegs = currentLegs.map((leg: any) => ({
...leg,
locations: leg.locations.map(loc =>
locations: leg.locations.map((loc: any) =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
: loc
@@ -1007,12 +1078,10 @@ export const TourDetailPage = ({
useTourStore.setState({ legs: updatedLegs });
};
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const setMapCenter = useTourStore(state => state.setMapCenter);
const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint);
const updateTourEndPoint = useTourStore(state => state.updateTourEndPoint);
const updateTourDetails = useTourStore(state => state.updateTourDetails); // Thêm action này
const initializeLegs = useTourStore(state => state.initializeLegs);
const addLocation = useTourStore(state => state.addLocation);
const removeMember = useTourStore(state => state.removeMember);
const fetchJoinRequests = useTourStore(state => state.fetchJoinRequests);
@@ -1083,7 +1152,7 @@ export const TourDetailPage = ({
const photosInLeg = currentTour.photos.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId));
if (photosInLeg.length > 0 && !selectedPhotoForDisplay) {
setSelectedPhotoForDisplay(getMostLikedPhoto(photosInLeg));
} else if (selectedPhotoForDisplay && !photosInLeg.some(p => p.id === selectedPhotoForDisplay.id)) {
} else if (selectedPhotoForDisplay && !photosInLeg.some((p: any) => p.id === selectedPhotoForDisplay.id)) {
setSelectedPhotoForDisplay(photosInLeg.length > 0 ? getMostLikedPhoto(photosInLeg) : null);
}
}
@@ -1134,8 +1203,6 @@ export const TourDetailPage = ({
coordsArray.unshift(`${userLocation[1]},${userLocation[0]}`);
}
const coordsString = coordsArray.join(';');
setIsRoutingLoading(true);
try {
const segmentPromises: Promise<OSRMRoute[] | null>[] = [];
@@ -1984,10 +2051,16 @@ export const TourDetailPage = ({
<MapIconLucide className="w-3.5 h-3.5" /> Bản đ
</button>
</div>
<div className="flex-1 flex justify-end">
<div className="flex-1 flex justify-end gap-2">
<button
onClick={handleExportCSV}
className="flex items-center gap-1.5 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95 whitespace-nowrap"
>
📊 Google Sheets
</button>
<button
onClick={handleExportPDF}
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95"
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95 whitespace-nowrap"
>
📥 {t('exportPDF') || 'Xuất PDF'}
</button>
@@ -2951,7 +3024,7 @@ export const TourDetailPage = ({
if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true);
}}
className="bg-blue-600 text-white px-4 py-2.5 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold text-sm">
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
{activeTab === 'plan' ? 'Thêm địa điểm' : 'Đăng ảnh'}
</button>
</div>
)}
@@ -3013,7 +3086,7 @@ export const TourDetailPage = ({
<Polyline positions={allLocations.map(l => [l.latitude, l.longitude]) as any} color="#3b82f6" weight={3} dashArray="5, 10" />
)}
{allLocations.map((loc: any, index: number) => {
{allLocations.map((loc: any) => {
const isStart = startPoint && startPoint.id === loc.id;
const isEnd = endPoint && endPoint.id === loc.id;
return (
+71
View File
@@ -0,0 +1,71 @@
/**
* Compresses an image file on the client side using the Canvas API.
* Resizes the image so that its maximum dimension is at most 2048px,
* and encodes it as image/jpeg with a quality of 0.85.
*
* @param file The original image File object.
* @returns A promise that resolves to the compressed File object.
*/
export function compressImage(file: File): Promise<File> {
return new Promise((resolve) => {
// If it's not an image, skip compression and return the original file
if (!file.type.startsWith('image/')) {
return resolve(file);
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
const maxDim = 2048;
if (width > maxDim || height > maxDim) {
if (width > height) {
height = Math.round((height * maxDim) / width);
width = maxDim;
} else {
width = Math.round((width * maxDim) / height);
height = maxDim;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return resolve(file);
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (blob) {
// Create a new File object with a .jpg extension
const newName = file.name.replace(/\.[^/.]+$/, "") + ".jpg";
const compressedFile = new File([blob], newName, {
type: 'image/jpeg',
lastModified: Date.now()
});
resolve(compressedFile);
} else {
resolve(file);
}
},
'image/jpeg',
0.85
);
};
img.onerror = () => {
resolve(file);
};
};
reader.onerror = () => {
resolve(file);
};
});
}
File diff suppressed because one or more lines are too long