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 { 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 (
|
||||
<ConfirmProvider>
|
||||
<NotificationProvider>
|
||||
{(() => {
|
||||
if (currentPage === 'tourDetail') {
|
||||
return (
|
||||
@@ -105,6 +107,7 @@ function App() {
|
||||
|
||||
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
|
||||
})()}
|
||||
</NotificationProvider>
|
||||
</ConfirmProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<AddMemberModalProps> = ({ 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<string | null>(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<AddMemberModalProps> = ({ 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<AddMemberModalProps> = ({ 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<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = ({
|
||||
</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) */}
|
||||
{isLegCountModalOpen && (
|
||||
|
||||
@@ -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<boolean>) | undefined>(undefined);
|
||||
|
||||
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
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<boolean>((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<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);
|
||||
}
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[5000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onClose} />
|
||||
<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">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-gray-900 flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-blue-600" />
|
||||
Bình luận
|
||||
</h3>
|
||||
<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>
|
||||
<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;
|
||||
};
|
||||
@@ -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 { 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
|
||||
isOpen={notificationModal.modalState?.isOpen ?? false}
|
||||
title={notificationModal.modalState?.title}
|
||||
message={notificationModal.modalState?.message}
|
||||
type={notificationModal.modalState?.type}
|
||||
onConfirm={() => notificationModal.closeModal()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<string | null>(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
|
||||
</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
|
||||
isOpen={isCommentModalOpen}
|
||||
onClose={() => setIsCommentModalOpen(false)}
|
||||
|
||||
Reference in New Issue
Block a user