feat: tạo hook useNotification.tsx để dùng chung toàn hệ thống
This commit is contained in:
@@ -5,6 +5,7 @@ import { TourDetailPage } from './pages/TourDetailPage';
|
|||||||
import { SignupPage } from './pages/SignupPage';
|
import { SignupPage } from './pages/SignupPage';
|
||||||
import { useTourStore } from './store/useTourStore';
|
import { useTourStore } from './store/useTourStore';
|
||||||
import { ConfirmProvider } from './hooks/useConfirm';
|
import { ConfirmProvider } from './hooks/useConfirm';
|
||||||
|
import { NotificationProvider } from './hooks/useNotification';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -84,6 +85,7 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
|
<NotificationProvider>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (currentPage === 'tourDetail') {
|
if (currentPage === 'tourDetail') {
|
||||||
return (
|
return (
|
||||||
@@ -105,6 +107,7 @@ function App() {
|
|||||||
|
|
||||||
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
|
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
|
||||||
})()}
|
})()}
|
||||||
|
</NotificationProvider>
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
|
|||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { X, MapPin, Loader2, Clock, Map as MapIcon, Navigation, Search } from 'lucide-react';
|
import { X, MapPin, Loader2, Clock, Map as MapIcon, Navigation, Search } from 'lucide-react';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
|
|
||||||
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
||||||
const { legs, addLocation, updateLocation, mapCenter, currentTour, userRole } = useTourStore();
|
const { legs, addLocation, updateLocation, mapCenter, currentTour, userRole } = useTourStore();
|
||||||
|
const notify = useNotification();
|
||||||
|
|
||||||
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -222,7 +224,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
|
|
||||||
const handleUseCurrentLocation = () => {
|
const handleUseCurrentLocation = () => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
alert("Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị GPS.");
|
notify({ title: 'Thông báo', message: "Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị GPS.", type: 'info' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +264,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
break;
|
break;
|
||||||
default: errorMessage += err.message;
|
default: errorMessage += err.message;
|
||||||
}
|
}
|
||||||
alert(errorMessage);
|
notify({ title: 'Lỗi định vị', message: errorMessage, type: 'error' });
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enableHighAccuracy: true, // Ưu tiên dùng GPS thay vì Wifi/Cell tower
|
enableHighAccuracy: true, // Ưu tiên dùng GPS thay vì Wifi/Cell tower
|
||||||
@@ -294,7 +296,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Lỗi khi lưu địa điểm');
|
notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react';
|
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react';
|
||||||
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
|
|
||||||
interface AddMemberModalProps {
|
interface AddMemberModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -22,10 +24,11 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [fetchError, setFetchError] = useState('');
|
const [fetchError, setFetchError] = useState('');
|
||||||
const [submitError, setSubmitError] = useState('');
|
const [submitError, setSubmitError] = useState('');
|
||||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
|
||||||
const [confirmTarget, setConfirmTarget] = useState<{ userId: string; name: string } | null>(null);
|
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotification();
|
||||||
|
|
||||||
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
|
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
|
||||||
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
|
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
|
||||||
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||||
@@ -66,19 +69,17 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
|
|
||||||
const handleRemove = async (userId: string, memberName: string) => {
|
const handleRemove = async (userId: string, memberName: string) => {
|
||||||
if (!onRemoveMember) return;
|
if (!onRemoveMember) return;
|
||||||
setConfirmTarget({ userId, name: memberName });
|
const isConfirmed = await confirm({
|
||||||
setIsConfirmOpen(true);
|
title: 'Xóa thành viên',
|
||||||
};
|
message: `Bạn có chắc chắn muốn xóa ${memberName} khỏi tour?`
|
||||||
|
});
|
||||||
|
|
||||||
const confirmRemove = async () => {
|
if (isConfirmed) {
|
||||||
if (!confirmTarget || !onRemoveMember) return;
|
try {
|
||||||
try {
|
await onRemoveMember(userId);
|
||||||
await onRemoveMember(confirmTarget.userId);
|
} catch (err: any) {
|
||||||
} catch (err: any) {
|
setSubmitError(err.message || 'Không thể xóa thành viên');
|
||||||
setSubmitError(err.message || 'Không thể xóa thành viên');
|
}
|
||||||
} finally {
|
|
||||||
setIsConfirmOpen(false);
|
|
||||||
setConfirmTarget(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
}
|
}
|
||||||
await onMemberAdded();
|
await onMemberAdded();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Thao tác thất bại');
|
notify({ title: 'Lỗi', message: err.message || 'Thao tác thất bại', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -319,26 +320,6 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isConfirmOpen && (
|
|
||||||
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={() => setIsConfirmOpen(false)} />
|
|
||||||
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
|
|
||||||
<h3 className="text-base font-bold text-gray-900">Xác nhận xóa thành viên</h3>
|
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
|
||||||
Bạn có chắc muốn xóa <span className="font-semibold text-gray-800">{confirmTarget?.name}</span> khỏi tour này?
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
|
||||||
<button onClick={() => setIsConfirmOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
|
||||||
Hủy
|
|
||||||
</button>
|
|
||||||
<button onClick={confirmRemove} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
|
|
||||||
Xóa
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
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 } from 'lucide-react';
|
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare } from 'lucide-react';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { ConfirmModal } from '@/components/ConfirmModal';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { CommentModal } from '@/components/CommentModal';
|
import { CommentModal } from '@/components/CommentModal';
|
||||||
|
|
||||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||||
@@ -58,7 +59,8 @@ export const ItineraryTimeline = ({
|
|||||||
// Khai báo logic canEdit để sử dụng trong toàn bộ component
|
// Khai báo logic canEdit để sử dụng trong toàn bộ component
|
||||||
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
|
|
||||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotification();
|
||||||
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
|
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
|
||||||
const [tempLegCount, setTempLegCount] = useState(3);
|
const [tempLegCount, setTempLegCount] = useState(3);
|
||||||
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
||||||
@@ -151,35 +153,31 @@ export const ItineraryTimeline = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLeg = async (legId: string) => {
|
const handleDeleteLeg = async (legId: string) => {
|
||||||
setConfirmState({
|
const isConfirmed = await confirm({
|
||||||
open: true,
|
|
||||||
title: 'Xóa chặng',
|
title: 'Xóa chặng',
|
||||||
message: 'Bạn có chắc chắn muốn xóa chặng này?',
|
message: 'Bạn có chắc chắn muốn xóa chặng này?'
|
||||||
onConfirm: async () => {
|
|
||||||
try {
|
|
||||||
await deleteLeg(legId);
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message);
|
|
||||||
} finally {
|
|
||||||
setConfirmState({ open: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (isConfirmed) {
|
||||||
|
try {
|
||||||
|
await deleteLeg(legId);
|
||||||
|
} catch (err: any) {
|
||||||
|
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLocation = async (id: string) => {
|
const handleDeleteLocation = async (id: string) => {
|
||||||
setConfirmState({
|
const isConfirmed = await confirm({
|
||||||
open: true,
|
|
||||||
title: 'Xóa địa điểm',
|
title: 'Xóa địa điểm',
|
||||||
message: 'Bạn có chắc chắn muốn xóa địa điểm này?',
|
message: 'Bạn có chắc chắn muốn xóa địa điểm này?'
|
||||||
onConfirm: async () => {
|
|
||||||
try {
|
|
||||||
await deleteLocation(id);
|
|
||||||
} catch (err: any) { alert(err.message); } finally {
|
|
||||||
setConfirmState({ open: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (isConfirmed) {
|
||||||
|
try {
|
||||||
|
await deleteLocation(id);
|
||||||
|
} catch (err: any) {
|
||||||
|
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -454,13 +452,6 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ConfirmModal
|
|
||||||
isOpen={confirmState.open}
|
|
||||||
title={confirmState.title}
|
|
||||||
message={confirmState.message}
|
|
||||||
onConfirm={() => confirmState.onConfirm?.()}
|
|
||||||
onCancel={() => setConfirmState({ open: false })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal Khai báo số chặng (Popover) */}
|
{/* Modal Khai báo số chặng (Popover) */}
|
||||||
{isLegCountModalOpen && (
|
{isLegCountModalOpen && (
|
||||||
|
|||||||
@@ -1,212 +1,60 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
import { io } from 'socket.io-client';
|
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
|
||||||
import { useConfirm } from '@/hooks/useConfirm';
|
|
||||||
|
|
||||||
interface Comment {
|
interface ConfirmOptions {
|
||||||
id: string;
|
title?: string;
|
||||||
userName: string;
|
message?: string;
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
userId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommentModalProps {
|
const ConfirmContext = createContext<((options: ConfirmOptions) => Promise<boolean>) | undefined>(undefined);
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
locationId: string;
|
|
||||||
locationName: string;
|
|
||||||
onCommentAdded?: () => void; // Callback to update comment count on parent
|
|
||||||
onCommentDeleted?: () => void;
|
|
||||||
isPublicView?: boolean; // New prop to indicate public view
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => {
|
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [state, setState] = useState<{
|
||||||
const [newComment, setNewComment] = useState('');
|
isOpen: boolean;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
title?: string;
|
||||||
const confirm = useConfirm();
|
message?: string;
|
||||||
|
resolve?: (value: boolean) => void;
|
||||||
|
}>({ isOpen: false });
|
||||||
|
|
||||||
const userRole = useTourStore(state => state.userRole);
|
const confirm = useCallback((options: ConfirmOptions) => {
|
||||||
const currentUserId = React.useMemo(() => {
|
return new Promise<boolean>((resolve) => {
|
||||||
try {
|
setState({
|
||||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
isOpen: true,
|
||||||
return user.id;
|
title: options.title,
|
||||||
} catch { return null; }
|
message: options.message,
|
||||||
|
resolve,
|
||||||
|
});
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchComments = async () => {
|
const handleConfirm = () => {
|
||||||
setIsLoading(true);
|
const resolve = state.resolve;
|
||||||
try {
|
setState({ isOpen: false, resolve: undefined });
|
||||||
const token = localStorage.getItem('token');
|
resolve?.(true);
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
|
|
||||||
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setComments(data.map((c: any) => ({
|
|
||||||
id: c.id,
|
|
||||||
userName: c.user?.name || 'Ẩn danh',
|
|
||||||
content: c.content,
|
|
||||||
createdAt: c.createdAt,
|
|
||||||
userId: c.userId
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Lỗi khi tải bình luận:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleCancel = () => {
|
||||||
if (!isOpen || !locationId) return;
|
const resolve = state.resolve;
|
||||||
|
setState({ isOpen: false, resolve: undefined });
|
||||||
fetchComments();
|
resolve?.(false);
|
||||||
|
|
||||||
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
|
|
||||||
const socket = io();
|
|
||||||
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
|
|
||||||
|
|
||||||
socket.on('commentAdded', (newCommentData: any) => {
|
|
||||||
if (newCommentData.locationId === locationId) {
|
|
||||||
setComments(prev => {
|
|
||||||
// Tránh trùng lặp nếu chính mình gửi
|
|
||||||
if (prev.find(c => c.id === newCommentData.id)) return prev;
|
|
||||||
return [...prev, {
|
|
||||||
id: newCommentData.id,
|
|
||||||
userName: newCommentData.user?.name || 'Ẩn danh',
|
|
||||||
content: newCommentData.content,
|
|
||||||
createdAt: newCommentData.createdAt
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => { socket.disconnect(); };
|
|
||||||
}, [isOpen, locationId]);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (!newComment.trim()) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ content: newComment })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setNewComment('');
|
|
||||||
fetchComments();
|
|
||||||
onCommentAdded?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Lỗi khi gửi bình luận:', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (commentId: string) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/v1/locations/comments/${commentId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setComments(prev => prev.filter(c => c.id !== commentId));
|
|
||||||
onCommentDeleted?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Lỗi khi xóa bình luận:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[5000] flex items-center justify-center p-4">
|
<ConfirmContext.Provider value={confirm}>
|
||||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onClose} />
|
{children}
|
||||||
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
|
<ConfirmModal
|
||||||
{/* Header */}
|
isOpen={state.isOpen}
|
||||||
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
|
title={state.title}
|
||||||
<div>
|
message={state.message}
|
||||||
<h3 className="text-xl font-black text-gray-900 flex items-center gap-2">
|
onConfirm={handleConfirm}
|
||||||
<MessageSquare className="w-5 h-5 text-blue-600" />
|
onCancel={handleCancel}
|
||||||
Bình luận
|
/>
|
||||||
</h3>
|
</ConfirmContext.Provider>
|
||||||
<p className="text-xs text-gray-500 font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
|
||||||
<X className="w-5 h-5 text-gray-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comment List */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50/50">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex justify-center py-10"><Loader2 className="w-6 h-6 animate-spin text-blue-600" /></div>
|
|
||||||
) : comments.length === 0 ? (
|
|
||||||
<div className="text-center py-10 text-gray-400 italic text-sm">Chưa có bình luận nào.</div>
|
|
||||||
) : (
|
|
||||||
comments.map((c) => (
|
|
||||||
<div key={c.id} className="flex gap-3 animate-in slide-in-from-left-2 duration-300">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 border border-blue-200">
|
|
||||||
<User className="w-4 h-4 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm">
|
|
||||||
<div className="flex justify-between items-start mb-1">
|
|
||||||
<p className="text-xs font-black text-gray-900">{c.userName}</p>
|
|
||||||
{(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && (
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
const isConfirmed = await confirm({
|
|
||||||
title: 'Xóa bình luận',
|
|
||||||
message: 'Bạn có chắc chắn muốn xóa bình luận này không? Hành động này sẽ không thể hoàn tác.'
|
|
||||||
});
|
|
||||||
if (isConfirmed) handleDelete(c.id);
|
|
||||||
}}
|
|
||||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 leading-relaxed">{c.content}</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-400 mt-1 ml-1 font-medium">
|
|
||||||
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input Area */}
|
|
||||||
<div className="p-4 bg-white border-t border-gray-100">
|
|
||||||
<div className="relative flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newComment}
|
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
|
||||||
placeholder={isPublicView ? 'Đăng nhập để bình luận...' : 'Viết bình luận...'}
|
|
||||||
className="flex-1 bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
|
||||||
/>
|
|
||||||
<button onClick={handleSend} disabled={!newComment.trim() || isPublicView} className="p-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 disabled:opacity-50 disabled:bg-gray-300 transition-all active:scale-95 shadow-lg shadow-blue-100">
|
|
||||||
<Send className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useConfirm = () => {
|
||||||
|
const confirm = useContext(ConfirmContext);
|
||||||
|
if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider');
|
||||||
|
return confirm;
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { NotificationModal } from '../components/NotificationModal';
|
||||||
|
|
||||||
|
interface NotificationOptions {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type?: 'success' | 'error' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationContext = createContext<((options: NotificationOptions) => void) | undefined>(undefined);
|
||||||
|
|
||||||
|
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
type?: 'success' | 'error' | 'info';
|
||||||
|
}>({ isOpen: false });
|
||||||
|
|
||||||
|
const notify = useCallback(({ title, message, type = 'info' }: NotificationOptions) => {
|
||||||
|
setState({
|
||||||
|
isOpen: true,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, isOpen: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Tự động đóng sau 3 giây nếu modal đang mở
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.isOpen) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
handleClose();
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer); // Xóa timer nếu người dùng bấm nút đóng trước 3 giây hoặc thông báo mới đè lên
|
||||||
|
}
|
||||||
|
}, [state.isOpen, handleClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={notify}>
|
||||||
|
{children}
|
||||||
|
<NotificationModal
|
||||||
|
isOpen={state.isOpen}
|
||||||
|
title={state.title}
|
||||||
|
message={state.message}
|
||||||
|
type={state.type}
|
||||||
|
onConfirm={handleClose}
|
||||||
|
/>
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNotification = () => {
|
||||||
|
const context = useContext(NotificationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotification must be used within a NotificationProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@ import 'leaflet/dist/leaflet.css';
|
|||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon } from 'lucide-react';
|
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon } from 'lucide-react';
|
||||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||||
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { CreateTourModal } from '../components/CreateTourModal';
|
import { CreateTourModal } from '../components/CreateTourModal';
|
||||||
|
|
||||||
// Fix lỗi icon mặc định của Leaflet
|
// Fix lỗi icon mặc định của Leaflet
|
||||||
@@ -63,7 +63,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
|
|
||||||
const notificationModal = useNotificationModal();
|
const notify = useNotification();
|
||||||
|
|
||||||
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
|
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
|
||||||
const [initialViewState] = useState(() => {
|
const [initialViewState] = useState(() => {
|
||||||
@@ -104,7 +104,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
} else if (navigator.clipboard && window.isSecureContext) {
|
} else if (navigator.clipboard && window.isSecureContext) {
|
||||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!', 'success');
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Giải pháp dự phòng cho môi trường không có HTTPS
|
// Giải pháp dự phòng cho môi trường không có HTTPS
|
||||||
@@ -114,7 +118,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
textArea.select();
|
textArea.select();
|
||||||
try {
|
try {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!', 'success');
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
}
|
}
|
||||||
@@ -346,14 +354,6 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
onViewTour(tour.id);
|
onViewTour(tour.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NotificationModal
|
|
||||||
isOpen={notificationModal.modalState?.isOpen ?? false}
|
|
||||||
title={notificationModal.modalState?.title}
|
|
||||||
message={notificationModal.modalState?.message}
|
|
||||||
type={notificationModal.modalState?.type}
|
|
||||||
onConfirm={() => notificationModal.closeModal()}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -5,8 +5,8 @@ import { ExpenseManager } from '../components/ExpenseManager';
|
|||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { AddLocationModal } from '@/components/AddLocationModal';
|
import { AddLocationModal } from '@/components/AddLocationModal';
|
||||||
import { AddMemberModal } from '../components/AddMemberModal';
|
import { AddMemberModal } from '../components/AddMemberModal';
|
||||||
import { ConfirmModal } from '../components/ConfirmModal';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { CommentModal } from '@/components/CommentModal';
|
import { CommentModal } from '@/components/CommentModal';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet';
|
||||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
@@ -197,7 +197,6 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
const [commentLocationId, setCommentLocationId] = useState('');
|
const [commentLocationId, setCommentLocationId] = useState('');
|
||||||
const [commentLocationName, setCommentLocationName] = useState('');
|
const [commentLocationName, setCommentLocationName] = useState('');
|
||||||
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
||||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
|
||||||
|
|
||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
|
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
|
||||||
@@ -248,7 +247,8 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
|
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
|
||||||
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
||||||
|
|
||||||
const notificationModal = useNotificationModal();
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotification();
|
||||||
|
|
||||||
// Khôi phục vị trí và mức zoom từ localStorage
|
// Khôi phục vị trí và mức zoom từ localStorage
|
||||||
const [initialViewState] = useState(() => {
|
const [initialViewState] = useState(() => {
|
||||||
@@ -304,7 +304,11 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
} else if (navigator.clipboard && window.isSecureContext) {
|
} else if (navigator.clipboard && window.isSecureContext) {
|
||||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success');
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: 'Đã sao chép liên kết chia sẻ chuyến đi!',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Giải pháp dự phòng cho môi trường không có HTTPS (truy cập qua IP)
|
// Giải pháp dự phòng cho môi trường không có HTTPS (truy cập qua IP)
|
||||||
@@ -314,7 +318,11 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
textArea.select();
|
textArea.select();
|
||||||
try {
|
try {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success');
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: 'Đã sao chép liên kết chia sẻ chuyến đi!',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
}
|
}
|
||||||
@@ -450,10 +458,14 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
childDiscount: childDiscountInput,
|
childDiscount: childDiscountInput,
|
||||||
tags: tagsInput
|
tags: tagsInput
|
||||||
});
|
});
|
||||||
notificationModal.openModal('Thành công', 'Đã cập nhật thông tin chuyến đi.', 'success');
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: 'Đã cập nhật thông tin chuyến đi.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notificationModal.openModal('Lỗi', error.message || 'Không thể cập nhật thông tin.', 'error');
|
notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật thông tin.', type: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
|
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
|
||||||
@@ -609,23 +621,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!currentTour) return;
|
if (!currentTour) return;
|
||||||
setConfirmState({
|
const isConfirmed = await confirm({
|
||||||
open: true,
|
|
||||||
title: 'Chấp nhận yêu cầu',
|
title: 'Chấp nhận yêu cầu',
|
||||||
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
|
||||||
onConfirm: async () => {
|
|
||||||
setJoinRequestActionId(req.id);
|
|
||||||
try {
|
|
||||||
await acceptJoinRequest(currentTour.id, req.id);
|
|
||||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
||||||
} catch (e: any) {
|
|
||||||
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
|
||||||
} finally {
|
|
||||||
setJoinRequestActionId(null);
|
|
||||||
setConfirmState({ open: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (isConfirmed) {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await acceptJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
notify({ title: 'Thông báo', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
|
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
|
||||||
aria-label="Accept"
|
aria-label="Accept"
|
||||||
@@ -638,23 +648,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!currentTour) return;
|
if (!currentTour) return;
|
||||||
setConfirmState({
|
const isConfirmed = await confirm({
|
||||||
open: true,
|
|
||||||
title: 'Từ chối yêu cầu',
|
title: 'Từ chối yêu cầu',
|
||||||
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
|
||||||
onConfirm: async () => {
|
|
||||||
setJoinRequestActionId(req.id);
|
|
||||||
try {
|
|
||||||
await rejectJoinRequest(currentTour.id, req.id);
|
|
||||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
||||||
} catch (e: any) {
|
|
||||||
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
|
||||||
} finally {
|
|
||||||
setJoinRequestActionId(null);
|
|
||||||
setConfirmState({ open: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (isConfirmed) {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await rejectJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
notify({ title: 'Thông báo', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
|
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
|
||||||
aria-label="Reject"
|
aria-label="Reject"
|
||||||
@@ -905,23 +913,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
disabled={joinRequestActionId === req.id}
|
disabled={joinRequestActionId === req.id}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!currentTour) return;
|
if (!currentTour) return;
|
||||||
setConfirmState({
|
const isConfirmed = await confirm({
|
||||||
open: true,
|
|
||||||
title: 'Chấp nhận yêu cầu',
|
title: 'Chấp nhận yêu cầu',
|
||||||
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
|
||||||
onConfirm: async () => {
|
|
||||||
setJoinRequestActionId(req.id);
|
|
||||||
try {
|
|
||||||
await acceptJoinRequest(currentTour.id, req.id);
|
|
||||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
||||||
} catch (e: any) {
|
|
||||||
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
|
||||||
} finally {
|
|
||||||
setJoinRequestActionId(null);
|
|
||||||
setConfirmState({ open: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (isConfirmed) {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await acceptJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
notify({ title: 'Thông báo', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50"
|
className="p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50"
|
||||||
aria-label="Accept"
|
aria-label="Accept"
|
||||||
@@ -932,23 +938,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
disabled={joinRequestActionId === req.id}
|
disabled={joinRequestActionId === req.id}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!currentTour) return;
|
if (!currentTour) return;
|
||||||
setConfirmState({
|
const isConfirmed = await confirm({
|
||||||
open: true,
|
|
||||||
title: 'Từ chối yêu cầu',
|
title: 'Từ chối yêu cầu',
|
||||||
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
|
||||||
onConfirm: async () => {
|
|
||||||
setJoinRequestActionId(req.id);
|
|
||||||
try {
|
|
||||||
await rejectJoinRequest(currentTour.id, req.id);
|
|
||||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
||||||
} catch (e: any) {
|
|
||||||
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
|
||||||
} finally {
|
|
||||||
setJoinRequestActionId(null);
|
|
||||||
setConfirmState({ open: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (isConfirmed) {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await rejectJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
notify({ title: 'Thông báo', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50"
|
className="p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50"
|
||||||
aria-label="Reject"
|
aria-label="Reject"
|
||||||
@@ -1168,7 +1172,7 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
await removeMember(currentTour.id, selectedMember.userId);
|
await removeMember(currentTour.id, selectedMember.userId);
|
||||||
setIsMemberDetailOpen(false);
|
setIsMemberDetailOpen(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error');
|
notify({ title: 'Thông báo', message: 'Không thể xóa thành viên', type: 'error' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold"
|
className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold"
|
||||||
@@ -1191,20 +1195,6 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ConfirmModal
|
|
||||||
isOpen={confirmState.open}
|
|
||||||
title={confirmState.title}
|
|
||||||
message={confirmState.message}
|
|
||||||
onConfirm={() => confirmState.onConfirm?.()}
|
|
||||||
onCancel={() => setConfirmState({ open: false })}
|
|
||||||
/>
|
|
||||||
<NotificationModal
|
|
||||||
isOpen={notificationModal.modalState?.isOpen ?? false}
|
|
||||||
title={notificationModal.modalState?.title}
|
|
||||||
message={notificationModal.modalState?.message}
|
|
||||||
type={notificationModal.modalState?.type}
|
|
||||||
onConfirm={() => notificationModal.closeModal()}
|
|
||||||
/>
|
|
||||||
<CommentModal
|
<CommentModal
|
||||||
isOpen={isCommentModalOpen}
|
isOpen={isCommentModalOpen}
|
||||||
onClose={() => setIsCommentModalOpen(false)}
|
onClose={() => setIsCommentModalOpen(false)}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
|
||||||
import { ConfirmModal } from '../components/ConfirmModal';
|
|
||||||
|
|
||||||
interface ConfirmOptions {
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConfirmContext = createContext<((options: ConfirmOptions) => Promise<boolean>) | undefined>(undefined);
|
|
||||||
|
|
||||||
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
||||||
const [state, setState] = useState<{
|
|
||||||
isOpen: boolean;
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
resolve?: (value: boolean) => void;
|
|
||||||
}>({ isOpen: false });
|
|
||||||
|
|
||||||
const confirm = useCallback((options: ConfirmOptions) => {
|
|
||||||
return new Promise<boolean>((resolve) => {
|
|
||||||
setState({
|
|
||||||
isOpen: true,
|
|
||||||
title: options.title,
|
|
||||||
message: options.message,
|
|
||||||
resolve,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
const resolve = state.resolve;
|
|
||||||
setState({ isOpen: false, resolve: undefined });
|
|
||||||
resolve?.(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
const resolve = state.resolve;
|
|
||||||
setState({ isOpen: false, resolve: undefined });
|
|
||||||
resolve?.(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConfirmContext.Provider value={confirm}>
|
|
||||||
{children}
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={state.isOpen}
|
|
||||||
title={state.title}
|
|
||||||
message={state.message}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
/>
|
|
||||||
</ConfirmContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useConfirm = () => {
|
|
||||||
const confirm = useContext(ConfirmContext);
|
|
||||||
if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider');
|
|
||||||
return confirm;
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user