From bfd18e05dd12022f8f4bf0bb4431ac0f5bababaf Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 16 Jun 2026 12:41:00 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20t=E1=BA=A1o=20hook=20useNotification.ts?= =?UTF-8?q?x=20=C4=91=E1=BB=83=20d=C3=B9ng=20chung=20to=C3=A0n=20h?= =?UTF-8?q?=E1=BB=87=20th=E1=BB=91ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 3 + frontend/src/components/AddLocationModal.tsx | 8 +- frontend/src/components/AddMemberModal.tsx | 51 ++-- frontend/src/components/ItineraryTimeline.tsx | 53 ++-- frontend/src/hooks/useConfirm.tsx | 244 ++++-------------- frontend/src/hooks/useNotification.tsx | 64 +++++ frontend/src/pages/ExploreMap.tsx | 24 +- frontend/src/pages/TourDetailPage.tsx | 156 ++++++----- useConfirm.tsx | 60 ----- 9 files changed, 241 insertions(+), 422 deletions(-) create mode 100644 frontend/src/hooks/useNotification.tsx delete mode 100644 useConfirm.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23d3847..42f5527 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { TourDetailPage } from './pages/TourDetailPage'; import { SignupPage } from './pages/SignupPage'; import { useTourStore } from './store/useTourStore'; import { ConfirmProvider } from './hooks/useConfirm'; +import { NotificationProvider } from './hooks/useNotification'; function App() { const params = new URLSearchParams(window.location.search); @@ -84,6 +85,7 @@ function App() { return ( + {(() => { if (currentPage === 'tourDetail') { return ( @@ -105,6 +107,7 @@ function App() { return setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />; })()} + ); } diff --git a/frontend/src/components/AddLocationModal.tsx b/frontend/src/components/AddLocationModal.tsx index ae3617a..6ea5c0a 100644 --- a/frontend/src/components/AddLocationModal.tsx +++ b/frontend/src/components/AddLocationModal.tsx @@ -2,6 +2,7 @@ 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 { useTourStore } from '@/store/useTourStore'; +import { useNotification } from '@/hooks/useNotification'; import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-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 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 useEffect(() => { @@ -222,7 +224,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin const handleUseCurrentLocation = () => { 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; } @@ -262,7 +264,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin break; 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 @@ -294,7 +296,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin } onClose(); } 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 { setIsLoading(false); } diff --git a/frontend/src/components/AddMemberModal.tsx b/frontend/src/components/AddMemberModal.tsx index d9ad202..7c37816 100644 --- a/frontend/src/components/AddMemberModal.tsx +++ b/frontend/src/components/AddMemberModal.tsx @@ -1,5 +1,7 @@ import React, { useState, useEffect, useMemo } from '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 { isOpen: boolean; @@ -22,10 +24,11 @@ export const AddMemberModal: React.FC = ({ isOpen, onClose, const [submitting, setSubmitting] = useState(false); const [fetchError, setFetchError] = useState(''); const [submitError, setSubmitError] = useState(''); - const [isConfirmOpen, setIsConfirmOpen] = useState(false); - const [confirmTarget, setConfirmTarget] = useState<{ userId: string; name: string } | null>(null); const [actionLoading, setActionLoading] = useState(null); + const confirm = useConfirm(); + const notify = useNotification(); + 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 visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]); @@ -66,19 +69,17 @@ export const AddMemberModal: React.FC = ({ isOpen, onClose, const handleRemove = async (userId: string, memberName: string) => { if (!onRemoveMember) return; - setConfirmTarget({ userId, name: memberName }); - setIsConfirmOpen(true); - }; + const isConfirmed = await confirm({ + 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 (!confirmTarget || !onRemoveMember) return; - try { - await onRemoveMember(confirmTarget.userId); - } catch (err: any) { - setSubmitError(err.message || 'Không thể xóa thành viên'); - } finally { - setIsConfirmOpen(false); - setConfirmTarget(null); + if (isConfirmed) { + try { + await onRemoveMember(userId); + } catch (err: any) { + setSubmitError(err.message || 'Không thể xóa thành viên'); + } } }; @@ -99,7 +100,7 @@ export const AddMemberModal: React.FC = ({ isOpen, onClose, } await onMemberAdded(); } 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 { setActionLoading(null); } @@ -319,26 +320,6 @@ export const AddMemberModal: React.FC = ({ isOpen, onClose, - - {isConfirmOpen && ( -
-
setIsConfirmOpen(false)} /> -
-

Xác nhận xóa thành viên

-

- Bạn có chắc muốn xóa {confirmTarget?.name} khỏi tour này? -

-
- - -
-
-
- )}
); }; diff --git a/frontend/src/components/ItineraryTimeline.tsx b/frontend/src/components/ItineraryTimeline.tsx index 40274d2..c3d8af5 100644 --- a/frontend/src/components/ItineraryTimeline.tsx +++ b/frontend/src/components/ItineraryTimeline.tsx @@ -2,7 +2,8 @@ import React, { useState, useEffect } 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 } from 'lucide-react'; 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'; 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 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 [tempLegCount, setTempLegCount] = useState(3); const [isCommentModalOpen, setIsCommentModalOpen] = useState(false); @@ -151,35 +153,31 @@ export const ItineraryTimeline = ({ }; const handleDeleteLeg = async (legId: string) => { - setConfirmState({ - open: true, + const isConfirmed = await confirm({ title: 'Xóa chặng', - 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 }); - } - }, + message: 'Bạn có chắc chắn muốn xóa chặng này?' }); + if (isConfirmed) { + try { + await deleteLeg(legId); + } catch (err: any) { + notify({ title: 'Lỗi', message: err.message, type: 'error' }); + } + } }; const handleDeleteLocation = async (id: string) => { - setConfirmState({ - open: true, + const isConfirmed = await confirm({ title: 'Xóa địa điểm', - 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 }); - } - }, + message: 'Bạn có chắc chắn muốn xóa địa điểm này?' }); + if (isConfirmed) { + try { + await deleteLocation(id); + } catch (err: any) { + notify({ title: 'Lỗi', message: err.message, type: 'error' }); + } + } }; return ( @@ -454,13 +452,6 @@ export const ItineraryTimeline = ({ )} - confirmState.onConfirm?.()} - onCancel={() => setConfirmState({ open: false })} - /> {/* Modal Khai báo số chặng (Popover) */} {isLegCountModalOpen && ( diff --git a/frontend/src/hooks/useConfirm.tsx b/frontend/src/hooks/useConfirm.tsx index e0e43c0..36d3c68 100644 --- a/frontend/src/hooks/useConfirm.tsx +++ b/frontend/src/hooks/useConfirm.tsx @@ -1,212 +1,60 @@ -import React, { useState, useEffect } from 'react'; -import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react'; -import { io } from 'socket.io-client'; -import { useTourStore } from '@/store/useTourStore'; -import { useConfirm } from '@/hooks/useConfirm'; +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { ConfirmModal } from '../components/ConfirmModal'; -interface Comment { - id: string; - userName: string; - content: string; - createdAt: string; - userId: string; +interface ConfirmOptions { + title?: string; + message?: string; } -interface CommentModalProps { - 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 -} +const ConfirmContext = createContext<((options: ConfirmOptions) => Promise) | undefined>(undefined); -export const CommentModal: React.FC = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => { - const [comments, setComments] = useState([]); - const [newComment, setNewComment] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const confirm = useConfirm(); +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 userRole = useTourStore(state => state.userRole); - const currentUserId = React.useMemo(() => { - try { - const user = JSON.parse(localStorage.getItem('user') || '{}'); - return user.id; - } catch { return null; } + const confirm = useCallback((options: ConfirmOptions) => { + return new Promise((resolve) => { + setState({ + isOpen: true, + title: options.title, + message: options.message, + resolve, + }); + }); }, []); - const fetchComments = async () => { - setIsLoading(true); - try { - const token = localStorage.getItem('token'); - const headers: Record = {}; - 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); - } + const handleConfirm = () => { + const resolve = state.resolve; + setState({ isOpen: false, resolve: undefined }); + resolve?.(true); }; - useEffect(() => { - if (!isOpen || !locationId) return; - - fetchComments(); - - // 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 handleCancel = () => { + const resolve = state.resolve; + setState({ isOpen: false, resolve: undefined }); + resolve?.(false); }; - 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 ( -
-
-
- {/* Header */} -
-
-

- - Bình luận -

-

{locationName}

-
- -
- - {/* Comment List */} -
- {isLoading ? ( -
- ) : comments.length === 0 ? ( -
Chưa có bình luận nào.
- ) : ( - comments.map((c) => ( -
-
- -
-
-
-
-

{c.userName}

- {(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && ( - - )} -
-

{c.content}

-
-

- {new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -

-
-
- )) - )} -
- - {/* Input Area */} -
-
- 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" - /> - -
-
-
-
+ + {children} + + ); +}; + +export const useConfirm = () => { + const confirm = useContext(ConfirmContext); + if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider'); + return confirm; }; \ No newline at end of file diff --git a/frontend/src/hooks/useNotification.tsx b/frontend/src/hooks/useNotification.tsx new file mode 100644 index 0000000..46a7a09 --- /dev/null +++ b/frontend/src/hooks/useNotification.tsx @@ -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 ( + + {children} + + + ); +}; + +export const useNotification = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error('useNotification must be used within a NotificationProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/pages/ExploreMap.tsx b/frontend/src/pages/ExploreMap.tsx index 4f32c15..53481e7 100644 --- a/frontend/src/pages/ExploreMap.tsx +++ b/frontend/src/pages/ExploreMap.tsx @@ -7,7 +7,7 @@ import 'leaflet/dist/leaflet.css'; import { useTourStore } from '@/store/useTourStore'; import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon } from 'lucide-react'; import { UserManagementModal } from '@/components/UserManagementModal'; -import { NotificationModal, useNotificationModal } from '@/components/NotificationModal'; +import { useNotification } from '@/hooks/useNotification'; import { CreateTourModal } from '../components/CreateTourModal'; // 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 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) const [initialViewState] = useState(() => { @@ -104,7 +104,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () }).catch(() => {}); } else if (navigator.clipboard && window.isSecureContext) { 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 { // 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(); try { 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) {} document.body.removeChild(textArea); } @@ -346,14 +354,6 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () onViewTour(tour.id); }} /> - - notificationModal.closeModal()} - />
); }; \ No newline at end of file diff --git a/frontend/src/pages/TourDetailPage.tsx b/frontend/src/pages/TourDetailPage.tsx index 9444f0f..b6a709d 100644 --- a/frontend/src/pages/TourDetailPage.tsx +++ b/frontend/src/pages/TourDetailPage.tsx @@ -5,8 +5,8 @@ import { ExpenseManager } from '../components/ExpenseManager'; import { useTourStore } from '@/store/useTourStore'; import { AddLocationModal } from '@/components/AddLocationModal'; import { AddMemberModal } from '../components/AddMemberModal'; -import { ConfirmModal } from '../components/ConfirmModal'; -import { NotificationModal, useNotificationModal } from '@/components/NotificationModal'; +import { useConfirm } from '@/hooks/useConfirm'; +import { useNotification } from '@/hooks/useNotification'; import { CommentModal } from '@/components/CommentModal'; import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet'; import _MarkerClusterGroup from 'react-leaflet-cluster'; @@ -197,7 +197,6 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac const [commentLocationId, setCommentLocationId] = useState(''); const [commentLocationName, setCommentLocationName] = useState(''); const [joinRequestActionId, setJoinRequestActionId] = useState(null); - const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false }); const fetchTour = useTourStore(state => state.fetchTour); 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 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 const [initialViewState] = useState(() => { @@ -304,7 +304,11 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac }).catch(() => {}); } else if (navigator.clipboard && window.isSecureContext) { 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 { // 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(); try { 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) {} document.body.removeChild(textArea); } @@ -450,10 +458,14 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac childDiscount: childDiscountInput, 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 } 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 @@ -609,23 +621,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac onClick={async (e) => { e.stopPropagation(); if (!currentTour) return; - setConfirmState({ - open: true, + const isConfirmed = await confirm({ title: 'Chấp nhận yêu cầu', - 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 }); - } - }, + message: `Chấp nhận ${req.user?.name || req.userId} vào tour?` }); + 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" aria-label="Accept" @@ -638,23 +648,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac onClick={async (e) => { e.stopPropagation(); if (!currentTour) return; - setConfirmState({ - open: true, + const isConfirmed = await confirm({ title: 'Từ chối yêu cầu', - 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 }); - } - }, + message: `Từ chối ${req.user?.name || req.userId} tham gia tour?` }); + 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" aria-label="Reject" @@ -905,23 +913,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac disabled={joinRequestActionId === req.id} onClick={async () => { if (!currentTour) return; - setConfirmState({ - open: true, + const isConfirmed = await confirm({ title: 'Chấp nhận yêu cầu', - 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 }); - } - }, + message: `Chấp nhận ${req.user?.name || req.userId} vào tour?` }); + 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" aria-label="Accept" @@ -932,23 +938,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac disabled={joinRequestActionId === req.id} onClick={async () => { if (!currentTour) return; - setConfirmState({ - open: true, + const isConfirmed = await confirm({ title: 'Từ chối yêu cầu', - 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 }); - } - }, + message: `Từ chối ${req.user?.name || req.userId} tham gia tour?` }); + 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" aria-label="Reject" @@ -1168,7 +1172,7 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac await removeMember(currentTour.id, selectedMember.userId); setIsMemberDetailOpen(false); } 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" @@ -1191,20 +1195,6 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac )} - confirmState.onConfirm?.()} - onCancel={() => setConfirmState({ open: false })} - /> - notificationModal.closeModal()} - /> setIsCommentModalOpen(false)} diff --git a/useConfirm.tsx b/useConfirm.tsx deleted file mode 100644 index 36d3c68..0000000 --- a/useConfirm.tsx +++ /dev/null @@ -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) | 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((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 ( - - {children} - - - ); -}; - -export const useConfirm = () => { - const confirm = useContext(ConfirmContext); - if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider'); - return confirm; -}; \ No newline at end of file