Thêm tính năng owner có thể thêm thành viên, member thêm thành viên sẽ pending
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
name: Local Config
|
||||
version: 1.0.0
|
||||
schema: v1
|
||||
models:
|
||||
- name: Autodetect
|
||||
provider: lmstudio
|
||||
model: AUTODETECT
|
||||
apiBase: http://192.168.1.12:1234/v1/
|
||||
+15
-5
@@ -5,6 +5,7 @@ import { useTourStore } from './useTourStore.js';
|
||||
import { AddLocationModal } from './AddLocationModal.js';
|
||||
import { AddMemberModal } from './AddMemberModal.js';
|
||||
import { ConfirmModal } from './ConfirmModal.js';
|
||||
import { NotificationModal, useNotificationModal } from './components/NotificationModal.js';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
@@ -181,6 +182,8 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
fetchJoinRequests, acceptJoinRequest, rejectJoinRequest
|
||||
} = useTourStore();
|
||||
|
||||
const notificationModal = useNotificationModal();
|
||||
|
||||
// Khôi phục vị trí và mức zoom từ localStorage
|
||||
const [initialViewState] = useState(() => {
|
||||
const saved = localStorage.getItem('map_view_state');
|
||||
@@ -443,7 +446,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
await acceptJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Không thể chấp nhận yêu cầu');
|
||||
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 });
|
||||
@@ -472,7 +475,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
await rejectJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Không thể từ chối yêu cầu');
|
||||
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 });
|
||||
@@ -721,7 +724,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
await acceptJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Không thể chấp nhận yêu cầu');
|
||||
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 });
|
||||
@@ -748,7 +751,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
await rejectJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Không thể từ chối yêu cầu');
|
||||
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 });
|
||||
@@ -849,7 +852,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
await removeMember(currentTour.id, selectedMember.userId);
|
||||
setIsMemberDetailOpen(false);
|
||||
} catch (e) {
|
||||
alert('Không thể xóa thành viên');
|
||||
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error');
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold"
|
||||
@@ -879,6 +882,13 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
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()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// Types for notification modal
|
||||
export interface NotificationModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
}
|
||||
|
||||
// Icon components for different types
|
||||
const Icons = {
|
||||
info: (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M12 16v-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M12 8h.01" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
),
|
||||
success: (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M8 12l2.5 2.5L15.5 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
),
|
||||
warning: (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="12" cy="17" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
error: (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// Default props
|
||||
const defaultProps: Partial<NotificationModalProps> = {
|
||||
title: 'Thông báo',
|
||||
type: 'info',
|
||||
confirmButtonText: 'OK',
|
||||
cancelButtonText: 'Hủy',
|
||||
};
|
||||
|
||||
export const NotificationModal: React.FC<NotificationModalProps> = ({
|
||||
isOpen,
|
||||
title = defaultProps.title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
type = defaultProps.type,
|
||||
confirmButtonText = defaultProps.confirmButtonText,
|
||||
cancelButtonText = defaultProps.cancelButtonText,
|
||||
}) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Get colors based on type
|
||||
const getTypeStyles = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return { bg: '#d4edda', border: '#c3e6cb', text: '#155724' };
|
||||
case 'warning':
|
||||
return { bg: '#fff3cd', border: '#ffeeba', text: '#856404' };
|
||||
case 'error':
|
||||
return { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' };
|
||||
default:
|
||||
return { bg: '#e2e3e5', border: '#d6d8db', text: '#383d41' };
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getTypeStyles();
|
||||
|
||||
// Animation classes based on state
|
||||
const getAnimationClass = () => {
|
||||
if (!isOpen) return 'opacity-0 translate-y-4';
|
||||
if (isAnimating && onCancel) return 'animate-fade-out';
|
||||
return 'animate-fade-in';
|
||||
};
|
||||
|
||||
// Handle confirm click
|
||||
const handleConfirm = () => {
|
||||
setIsAnimating(true);
|
||||
onConfirm?.();
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
};
|
||||
|
||||
// Handle cancel click
|
||||
const handleCancel = () => {
|
||||
setIsAnimating(true);
|
||||
onCancel?.();
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4">
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-300 ${getAnimationClass()}`}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-message"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`p-6 border-b ${styles.border}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{type === 'success' && <Icons.success className="w-5 h-5 text-green-600" />}
|
||||
{type === 'warning' && <Icons.warning className="w-5 h-5 text-yellow-600" />}
|
||||
{type === 'error' && <Icons.error className="w-5 h-5 text-red-600" />}
|
||||
{type === 'info' && <Icons.info className="w-5 h-5 text-blue-600" />}
|
||||
<h2 id="modal-title" className={`text-xl font-semibold ${styles.text}`}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6">
|
||||
<p id="modal-message" className="text-gray-700 leading-relaxed">{message}</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`px-6 py-4 flex justify-end gap-3 border-t ${styles.border}`}>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
{cancelButtonText}
|
||||
</button>
|
||||
)}
|
||||
{onConfirm && (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className={`px-4 py-2 text-white rounded-md font-medium transition-colors ${
|
||||
type === 'error'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{confirmButtonText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook for easy usage without props management
|
||||
export const useNotificationModal = () => {
|
||||
const [modalState, setModalState] = useState<{
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const openModal = (
|
||||
title: string,
|
||||
message: string,
|
||||
type: 'info' | 'success' | 'warning' | 'error' = 'info',
|
||||
onConfirm?: () => void,
|
||||
onCancel?: () => void,
|
||||
) => {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
});
|
||||
|
||||
// Auto-close after 5 seconds if no confirm action
|
||||
const timer = setTimeout(() => {
|
||||
if (onCancel) {
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalState((prev) => prev ? { ...prev, isOpen: false } : null);
|
||||
};
|
||||
|
||||
return {
|
||||
modalState,
|
||||
openModal,
|
||||
closeModal,
|
||||
};
|
||||
};
|
||||
|
||||
export default NotificationModal;
|
||||
Vendored
+8
-6
@@ -6,6 +6,7 @@ import { useTourStore } from './useTourStore.js';
|
||||
import { AddLocationModal } from './AddLocationModal.js';
|
||||
import { AddMemberModal } from './AddMemberModal.js';
|
||||
import { ConfirmModal } from './ConfirmModal.js';
|
||||
import { NotificationModal, useNotificationModal } from './components/NotificationModal.js';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = _MarkerClusterGroup.default || _MarkerClusterGroup;
|
||||
@@ -104,6 +105,7 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
const [joinRequestActionId, setJoinRequestActionId] = useState(null);
|
||||
const [confirmState, setConfirmState] = useState({ open: false });
|
||||
const { currentTour, legs, fetchTour, fetchPublicTours, publicTours, userRole, mapCenter, setMapCenter, updateTourStartPoint, updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember, fetchJoinRequests, acceptJoinRequest, rejectJoinRequest } = useTourStore();
|
||||
const notificationModal = useNotificationModal();
|
||||
const [initialViewState] = useState(() => {
|
||||
const saved = localStorage.getItem('map_view_state');
|
||||
if (saved) {
|
||||
@@ -267,7 +269,7 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
alert(e.message || 'Không thể chấp nhận yêu cầu');
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
@@ -290,7 +292,7 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
alert(e.message || 'Không thể từ chối yêu cầu');
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
@@ -333,7 +335,7 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
alert(e.message || 'Không thể chấp nhận yêu cầu');
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
@@ -355,7 +357,7 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
alert(e.message || 'Không thể từ chối yêu cầu');
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
@@ -376,11 +378,11 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
setIsMemberDetailOpen(false);
|
||||
}
|
||||
catch (e) {
|
||||
alert('Không thể xóa thành viên');
|
||||
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error');
|
||||
}
|
||||
}, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold", children: "X\u00F3a" })), canEdit && selectedMember.role === 'OWNER' && (_jsx("button", { onClick: () => {
|
||||
setIsMemberDetailOpen(false);
|
||||
setIsAddMemberOpen(true);
|
||||
}, className: "px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold", children: "M\u1EDDi th\u00EAm ng\u01B0\u1EDDi" }))] })] })] })), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) })] }));
|
||||
}, className: "px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold", children: "M\u1EDDi th\u00EAm ng\u01B0\u1EDDi" }))] })] })] })), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) }), _jsx(NotificationModal, { isOpen: notificationModal.modalState?.isOpen ?? false, title: notificationModal.modalState?.title, message: notificationModal.modalState?.message, type: notificationModal.modalState?.type, onConfirm: () => notificationModal.closeModal() })] }));
|
||||
};
|
||||
//# sourceMappingURL=TourDetailPage.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+25
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
export interface NotificationModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
}
|
||||
export declare const NotificationModal: React.FC<NotificationModalProps>;
|
||||
export declare const useNotificationModal: () => {
|
||||
modalState: {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
openModal: (title: string, message: string, type?: "info" | "success" | "warning" | "error", onConfirm?: () => void, onCancel?: () => void) => () => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
export default NotificationModal;
|
||||
Vendored
+81
@@ -0,0 +1,81 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
const Icons = {
|
||||
info: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M12 16v-4", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("path", { d: "M12 8h.01", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round" })] })),
|
||||
success: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M8 12l2.5 2.5L15.5 9", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })] })),
|
||||
warning: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("path", { d: "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z", stroke: "currentColor", strokeWidth: "2" }), _jsx("line", { x1: "12", y1: "9", x2: "12", y2: "13", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("circle", { cx: "12", cy: "17", r: "1", fill: "currentColor" })] })),
|
||||
error: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("line", { x1: "15", y1: "9", x2: "9", y2: "15", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("line", { x1: "9", y1: "9", x2: "15", y2: "15", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" })] })),
|
||||
};
|
||||
const defaultProps = {
|
||||
title: 'Thông báo',
|
||||
type: 'info',
|
||||
confirmButtonText: 'OK',
|
||||
cancelButtonText: 'Hủy',
|
||||
};
|
||||
export const NotificationModal = ({ isOpen, title = defaultProps.title, message, onConfirm, onCancel, type = defaultProps.type, confirmButtonText = defaultProps.confirmButtonText, cancelButtonText = defaultProps.cancelButtonText, }) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const getTypeStyles = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return { bg: '#d4edda', border: '#c3e6cb', text: '#155724' };
|
||||
case 'warning':
|
||||
return { bg: '#fff3cd', border: '#ffeeba', text: '#856404' };
|
||||
case 'error':
|
||||
return { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' };
|
||||
default:
|
||||
return { bg: '#e2e3e5', border: '#d6d8db', text: '#383d41' };
|
||||
}
|
||||
};
|
||||
const styles = getTypeStyles();
|
||||
const getAnimationClass = () => {
|
||||
if (!isOpen)
|
||||
return 'opacity-0 translate-y-4';
|
||||
if (isAnimating && onCancel)
|
||||
return 'animate-fade-out';
|
||||
return 'animate-fade-in';
|
||||
};
|
||||
const handleConfirm = () => {
|
||||
setIsAnimating(true);
|
||||
onConfirm?.();
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
};
|
||||
const handleCancel = () => {
|
||||
setIsAnimating(true);
|
||||
onCancel?.();
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
};
|
||||
if (!isOpen)
|
||||
return null;
|
||||
return (_jsx("div", { className: "fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4", children: _jsxs("div", { className: `bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-300 ${getAnimationClass()}`, role: "alertdialog", "aria-modal": "true", "aria-labelledby": "modal-title", "aria-describedby": "modal-message", children: [_jsx("div", { className: `p-6 border-b ${styles.border}`, children: _jsxs("div", { className: "flex items-center gap-3", children: [type === 'success' && _jsx(Icons.success, { className: "w-5 h-5 text-green-600" }), type === 'warning' && _jsx(Icons.warning, { className: "w-5 h-5 text-yellow-600" }), type === 'error' && _jsx(Icons.error, { className: "w-5 h-5 text-red-600" }), type === 'info' && _jsx(Icons.info, { className: "w-5 h-5 text-blue-600" }), _jsx("h2", { id: "modal-title", className: `text-xl font-semibold ${styles.text}`, children: title })] }) }), _jsx("div", { className: "p-6", children: _jsx("p", { id: "modal-message", className: "text-gray-700 leading-relaxed", children: message }) }), _jsxs("div", { className: `px-6 py-4 flex justify-end gap-3 border-t ${styles.border}`, children: [onCancel && (_jsx("button", { onClick: handleCancel, className: "px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors", children: cancelButtonText })), onConfirm && (_jsx("button", { onClick: handleConfirm, className: `px-4 py-2 text-white rounded-md font-medium transition-colors ${type === 'error'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'}`, children: confirmButtonText }))] })] }) }));
|
||||
};
|
||||
export const useNotificationModal = () => {
|
||||
const [modalState, setModalState] = useState(null);
|
||||
const openModal = (title, message, type = 'info', onConfirm, onCancel) => {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (onCancel) {
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setModalState((prev) => prev ? { ...prev, isOpen: false } : null);
|
||||
};
|
||||
return {
|
||||
modalState,
|
||||
openModal,
|
||||
closeModal,
|
||||
};
|
||||
};
|
||||
export default NotificationModal;
|
||||
//# sourceMappingURL=NotificationModal.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+21
-3
@@ -342,7 +342,13 @@ let TourController = class TourController {
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
}
|
||||
const currentRole = req.user.tourParticipation?.role;
|
||||
let currentRole = req.user.tourParticipation?.role;
|
||||
if (!currentRole) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||
});
|
||||
currentRole = participation?.role;
|
||||
}
|
||||
if (!currentRole || !(currentRole === 'OWNER' || currentRole === 'MANAGER')) {
|
||||
const joinRequest = await this.prisma.joinRequest.create({
|
||||
data: {
|
||||
@@ -407,7 +413,13 @@ let TourController = class TourController {
|
||||
return joinRequest;
|
||||
}
|
||||
async acceptJoinRequest(tourId, requestId, req) {
|
||||
const role = req.user.tourParticipation?.role;
|
||||
let role = req.user.tourParticipation?.role;
|
||||
if (!role) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||
});
|
||||
role = participation?.role;
|
||||
}
|
||||
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||
throw new ForbiddenException('Bạn không có quyền chấp nhận yêu cầu tham gia.');
|
||||
}
|
||||
@@ -446,7 +458,13 @@ let TourController = class TourController {
|
||||
return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' };
|
||||
}
|
||||
async rejectJoinRequest(tourId, requestId, req) {
|
||||
const role = req.user.tourParticipation?.role;
|
||||
let role = req.user.tourParticipation?.role;
|
||||
if (!role) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||
});
|
||||
role = participation?.role;
|
||||
}
|
||||
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||
throw new ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.');
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -21,7 +21,7 @@ let TourRoleGuard = class TourRoleGuard {
|
||||
if (!user || !tourId) {
|
||||
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||
}
|
||||
if (path.includes('/join-requests') && request.method === 'POST' && !request.params.requestId) {
|
||||
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
|
||||
request.tourParticipation = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAiC,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAG7C,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,kBAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAED,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC;YAC9F,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YACjC,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC;YACjE,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,MAAM,IAAI,kBAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAED,OAAO,CAAC,iBAAiB,GAAG,aAAa,CAAC;QAE1C,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAEjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,aAAa,CAAC;YACxD,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,kBAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA/CY,aAAa;IADzB,UAAU,EAAE;qCAEiB,aAAa;GAD9B,aAAa,CA+CzB"}
|
||||
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAiC,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAG7C,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,kBAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAED,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,0CAA0C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YACzJ,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YACjC,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC;YACjE,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,MAAM,IAAI,kBAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAED,OAAO,CAAC,iBAAiB,GAAG,aAAa,CAAC;QAE1C,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAEjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,aAAa,CAAC;YACxD,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,kBAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA/CY,aAAa;IADzB,UAAU,EAAE;qCAEiB,aAAa;GAD9B,aAAa,CA+CzB"}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -378,7 +378,13 @@ class TourController {
|
||||
});
|
||||
}
|
||||
|
||||
const currentRole = req.user.tourParticipation?.role;
|
||||
let currentRole = req.user.tourParticipation?.role;
|
||||
if (!currentRole) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||
});
|
||||
currentRole = participation?.role;
|
||||
}
|
||||
if (!currentRole || !(currentRole === 'OWNER' || currentRole === 'MANAGER')) {
|
||||
const joinRequest = await this.prisma.joinRequest.create({
|
||||
data: {
|
||||
@@ -458,7 +464,13 @@ class TourController {
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/join-requests/:requestId/accept')
|
||||
async acceptJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
|
||||
const role = req.user.tourParticipation?.role;
|
||||
let role = req.user.tourParticipation?.role;
|
||||
if (!role) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||
});
|
||||
role = participation?.role;
|
||||
}
|
||||
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||
throw new ForbiddenException('Bạn không có quyền chấp nhận yêu cầu tham gia.');
|
||||
}
|
||||
@@ -504,7 +516,13 @@ class TourController {
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/join-requests/:requestId/reject')
|
||||
async rejectJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
|
||||
const role = req.user.tourParticipation?.role;
|
||||
let role = req.user.tourParticipation?.role;
|
||||
if (!role) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||
});
|
||||
role = participation?.role;
|
||||
}
|
||||
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||
throw new ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.');
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export class TourRoleGuard implements CanActivate {
|
||||
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||
}
|
||||
|
||||
if (path.includes('/join-requests') && request.method === 'POST' && !request.params.requestId) {
|
||||
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
|
||||
request.tourParticipation = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user