feat: tính năng chia sẻ khẩn cấp

This commit is contained in:
2026-06-21 12:52:47 +07:00
parent 8d79cd76f6
commit 403c169ddd
21 changed files with 2939 additions and 132 deletions
+362 -8
View File
@@ -3,6 +3,7 @@ import { io } from 'socket.io-client';
import { ItineraryTimeline } from '../components/ItineraryTimeline';
import { ExpenseManager } from '../components/ExpenseManager';
import { useTourStore } from '@/store/useTourStore';
import { useTranslation } from '../hooks/useTranslation';
import { AddLocationModal } from '@/components/AddLocationModal';
import { AddMemberModal } from '../components/AddMemberModal';
import { MembersTab } from '../components/MembersTab';
@@ -253,7 +254,7 @@ const MapHoverTip = ({ canEdit }: { canEdit: boolean }) => {
);
};
// Menu ngữ cảnh cho bản đồ
const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => {
const MapContextMenu = ({ onAction, onOpen }: { onAction: (action: string, latlng: L.LatLng) => void; onOpen?: () => void }) => {
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
@@ -271,6 +272,7 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`);
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
if (onOpen) onOpen();
},
moveend: (e) => {
@@ -352,6 +354,9 @@ export const TourDetailPage = ({
onOpenNotes?: () => void
}) => {
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
const { t } = useTranslation();
const notify = useNotification();
const confirm = useConfirm();
const currentTour = useTourStore(state => state.currentTour);
const legs = useTourStore(state => state.legs);
const publicTours = useTourStore(state => state.publicTours);
@@ -378,6 +383,187 @@ export const TourDetailPage = ({
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
const [isRatingModalOpen, setIsRatingModalOpen] = useState(false);
const [ratingTargetUser, setRatingTargetUser] = useState<any>(null);
const [ratingScores, setRatingScores] = useState({
honesty: 5,
transparency: 5,
enthusiasm: 5,
cheerfulness: 5,
seriousness: 5,
planning: 5,
survival: 5
});
const [ratingComment, setRatingComment] = useState('');
const [isSubmittingRating, setIsSubmittingRating] = useState(false);
const handleSubmitRating = async () => {
if (!ratingTargetUser) return;
setIsSubmittingRating(true);
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/tours/${tourId}/ratings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
targetUserId: ratingTargetUser.userId || ratingTargetUser.user?.id,
...ratingScores,
comment: ratingComment
})
});
if (res.ok) {
notify({ title: 'Thành công', message: 'Cảm ơn bạn đã gửi đánh giá!', type: 'success' });
setIsRatingModalOpen(false);
setRatingComment('');
setRatingScores({
honesty: 5,
transparency: 5,
enthusiasm: 5,
cheerfulness: 5,
seriousness: 5,
planning: 5,
survival: 5
});
} else {
const err = await res.json();
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi gửi đánh giá.', type: 'error' });
}
} catch (error) {
console.error(error);
notify({ title: 'Lỗi', message: 'Lỗi mạng khi gửi đánh giá.', type: 'error' });
} finally {
setIsSubmittingRating(false);
}
};
const [shareStatus, setShareStatus] = useState<{ isEnabled: boolean; token: string } | null>(null);
const fetchShareStatus = async () => {
if (isPublicView) return;
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/tours/${tourId}/share`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setShareStatus(data);
}
} catch (e) {
console.error('Error fetching share status:', e);
}
};
const handleToggleShare = async (isEnabled: boolean) => {
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/tours/${tourId}/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ isEnabled })
});
if (res.ok) {
const data = await res.json();
setShareStatus(data);
notify({
title: 'Thành công',
message: isEnabled ? 'Đã bật chia sẻ hành trình cứu hộ.' : 'Đã tắt chia sẻ.',
type: 'success'
});
}
} catch (e) {
console.error(e);
}
};
const handleExportPDF = async () => {
if (!(window as any).html2pdf) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js';
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load html2pdf'));
document.head.appendChild(script);
});
}
const style = document.createElement('style');
style.innerHTML = `
.pdf-exclude { display: none !important; }
.pdf-container { padding: 40px !important; color: #000 !important; background: #fff !important; }
.pdf-title { font-size: 24px !important; font-weight: bold !important; margin-bottom: 20px !important; text-align: center !important; }
.pdf-timeline { margin-top: 20px; }
.pdf-location-card { border: 1px solid #e5e7eb; padding: 15px; border-radius: 12px; margin-bottom: 15px; background: #fafafa; }
.pdf-leg-header { font-size: 16px; font-weight: bold; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #3b82f6; padding-bottom: 5px; }
`;
document.head.appendChild(style);
const element = document.createElement('div');
element.className = 'pdf-container font-sans text-black bg-white';
const titleEl = document.createElement('h1');
titleEl.className = 'pdf-title';
titleEl.innerText = `Hành Trình: ${currentTour?.title || 'Tour Itinerary'}`;
element.appendChild(titleEl);
const subEl = document.createElement('div');
subEl.style.textAlign = 'center';
subEl.style.marginBottom = '30px';
subEl.style.fontSize = '12px';
subEl.style.color = '#555';
subEl.innerText = `Thời gian: ${currentTour?.startDate ? new Date(currentTour.startDate).toLocaleDateString('vi-VN') : ''} - ${currentTour?.endDate ? new Date(currentTour.endDate).toLocaleDateString('vi-VN') : ''}`;
element.appendChild(subEl);
const printDom = document.getElementById('itinerary-timeline-print-zone');
if (printDom) {
const clone = printDom.cloneNode(true) as HTMLElement;
// Clean clone layout by removing action buttons and interactive inputs, expenses
clone.querySelectorAll('button, input, textarea, .pdf-exclude, .expense-badge, .paid-by-badge, .comment-section, .location-actions, .mt-2.text-indigo-650, .flex.gap-2.mt-2').forEach(el => {
el.remove();
});
// Clear styles or apply simple standard styles so PDF generation is clean
clone.style.background = 'white';
clone.style.color = 'black';
element.appendChild(clone);
} else {
notify({ title: 'Lỗi', message: 'Không tìm thấy vùng hiển thị lịch trình để xuất PDF.', type: 'error' });
document.head.removeChild(style);
return;
}
const opt = {
margin: 10,
filename: `Lich_trinh_${currentTour?.title || 'tour'}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};
try {
notify({ title: 'Đang tạo PDF...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
await (window as any).html2pdf().from(element).set(opt).save();
notify({ title: 'Thành công', message: 'Lịch trình đã được xuất ra tập tin PDF thành công.', type: 'success' });
} catch (err) {
console.error(err);
notify({ title: 'Lỗi', message: 'Không thể xuất PDF lịch trình.', type: 'error' });
} finally {
document.head.removeChild(style);
}
};
useEffect(() => {
fetchShareStatus();
}, [tourId]);
// State cho vị trí và hướng của người dùng
@@ -834,8 +1020,6 @@ export const TourDetailPage = ({
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
const deleteTour = useTourStore(state => state.deleteTour);
const confirm = useConfirm();
const notify = useNotification();
// Khôi phục vị trí và mức zoom từ localStorage
const [initialViewState] = useState(() => {
@@ -1071,8 +1255,8 @@ export const TourDetailPage = ({
const handleNavigateToLocation = (location: any) => {
setMapCenter([location.latitude, location.longitude]);
setViewMode('map');
setLocateTrigger(prev => prev + 1);
setIsMapFullscreen(true);
notify({
title: 'Bắt đầu chỉ đường',
@@ -1784,21 +1968,30 @@ export const TourDetailPage = ({
{activeTab === 'plan' && (
<div className="animate-in fade-in slide-in-from-bottom-2">
{/* View Mode Toggle */}
<div className="flex justify-center mb-6">
<div className="bg-gray-100 p-1 rounded-2xl flex gap-1">
<div className="flex justify-between items-center mb-6">
<div className="flex-1" />
<div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1">
<button
onClick={() => setViewMode('timeline')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
>
<List className="w-3.5 h-3.5" /> Danh sách
</button>
<button
onClick={() => setViewMode('map')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
>
<MapIconLucide className="w-3.5 h-3.5" /> Bản đ
</button>
</div>
<div className="flex-1 flex justify-end">
<button
onClick={handleExportPDF}
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95"
>
📥 {t('exportPDF') || 'Xuất PDF'}
</button>
</div>
</div>
{viewMode === 'timeline' ? (
@@ -2538,6 +2731,54 @@ export const TourDetailPage = ({
</div>
</div>
{/* Emergency Sharing Settings */}
<div className="p-6 bg-white dark:bg-slate-900 rounded-3xl border border-dashed border-rose-200 dark:border-rose-950/40 shadow-sm animate-in zoom-in-95">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<span className="text-xl">🚨</span>
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{t('emergencyShare')}</h3>
<p className="text-xs text-gray-500 dark:text-slate-400">{t('emergencyShareTooltip')}</p>
</div>
</div>
{shareStatus && (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={shareStatus.isEnabled}
onChange={(e) => handleToggleShare(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-rose-500"></div>
</label>
)}
</div>
{shareStatus?.isEnabled && (
<div className="mt-4 bg-rose-50 dark:bg-rose-950/20 p-4 rounded-2xl border border-rose-100 dark:border-rose-950/30 flex flex-col gap-2">
<div className="text-xs font-bold text-rose-700 dark:text-rose-400 uppercase tracking-widest">Đưng dẫn chia sẻ khẩn cấp:</div>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${window.location.origin}/journey/${shareStatus.token}`}
className="flex-1 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-xl px-3 py-2 text-xs text-slate-800 dark:text-slate-100 select-all"
/>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/journey/${shareStatus.token}`);
notify({ title: 'Thành công', message: 'Đã sao chép liên kết!', type: 'success' });
}}
className="bg-rose-600 hover:bg-rose-700 text-white font-bold px-3 py-2 rounded-xl text-xs transition-all active:scale-95 shrink-0"
>
{t('copyShareLink')}
</button>
</div>
</div>
)}
</div>
{canEdit && (
<div className="p-6 bg-white rounded-3xl border border-gray-100 shadow-sm animate-in zoom-in-95">
<div className="flex items-center gap-3 mb-6">
@@ -2930,6 +3171,20 @@ export const TourDetailPage = ({
)}
<div className="mt-4 flex justify-end gap-2">
<button onClick={() => setIsMemberDetailOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100">Đóng</button>
{!isPublicView && currentUser && selectedMember && (selectedMember.userId || selectedMember.user?.id) && currentUser.id !== (selectedMember.userId || selectedMember.user?.id) && (selectedMember.role === 'OWNER' || selectedMember.role === 'MANAGER') && (
<button
onClick={() => {
setIsMemberDetailOpen(false);
setRatingTargetUser(selectedMember);
setIsRatingModalOpen(true);
}}
className="px-3 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-xl text-sm font-bold flex items-center gap-1 shadow-md transition-all active:scale-95"
>
{t('rateOrganizer') || 'Đánh giá'}
</button>
)}
{canEdit && selectedMember.role !== 'OWNER' && (
<button
onClick={async () => {
@@ -2961,6 +3216,105 @@ export const TourDetailPage = ({
</div>
</div>
)}
{/* Organizer Rating Modal */}
{isRatingModalOpen && ratingTargetUser && (
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in" onClick={() => setIsRatingModalOpen(false)} />
<div className="relative w-full max-w-lg bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 max-h-[90vh] overflow-y-auto flex flex-col animate-in zoom-in-95 duration-250 text-slate-800 dark:text-slate-100 border dark:border-slate-800">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-slate-950 dark:text-white flex items-center gap-2">
{t('rateTitle') || 'Đánh giá Người tạo Tour'}
</h3>
<button onClick={() => setIsRatingModalOpen(false)} className="p-2 hover:bg-gray-150 dark:hover:bg-slate-850 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<div className="flex items-center gap-3 mb-6 bg-slate-50 dark:bg-slate-850 p-4 rounded-2xl border border-slate-100 dark:border-slate-800">
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold overflow-hidden">
{ratingTargetUser.avatar ? (
<img src={ratingTargetUser.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{ratingTargetUser.user?.name?.charAt(0) || ratingTargetUser.displayName?.charAt(0) || '?'}</span>
)}
</div>
<div>
<div className="text-sm font-black dark:text-white">{ratingTargetUser.user?.name || ratingTargetUser.displayName}</div>
<div className="text-xs text-gray-450 uppercase tracking-widest font-bold">{ratingTargetUser.role}</div>
</div>
</div>
<div className="space-y-4 flex-1">
{[
{ key: 'honesty', label: t('honesty') || 'Trung thực' },
{ key: 'transparency', label: t('transparency') || 'Minh bạch' },
{ key: 'enthusiasm', label: t('enthusiasm') || 'Nhiệt tình' },
{ key: 'cheerfulness', label: t('cheerfulness') || 'Vui vẻ' },
{ key: 'seriousness', label: t('seriousness') || 'Nghiêm túc' },
{ key: 'planning', label: t('planning') || 'Có kế hoạch' },
{ key: 'survival', label: t('survival') || 'Kỹ năng sinh tồn' }
].map((c) => (
<div key={c.key} className="flex items-center justify-between border-b border-slate-100 dark:border-slate-850 pb-2">
<span className="text-xs font-bold text-slate-700 dark:text-slate-350">{c.label}</span>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => {
const currentVal = (ratingScores as any)[c.key];
return (
<button
key={star}
type="button"
onClick={() => setRatingScores(prev => ({ ...prev, [c.key]: star }))}
className={`w-6 h-6 text-xl transition-all active:scale-95 ${
star <= currentVal ? 'text-amber-450' : 'text-gray-300 dark:text-gray-700 hover:text-amber-300'
}`}
>
</button>
);
})}
</div>
</div>
))}
<div className="mt-4">
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Nhận xét khác</label>
<textarea
value={ratingComment}
onChange={(e) => setRatingComment(e.target.value)}
placeholder={t('rateCommentPlaceholder') || 'Nhập ý kiến đánh giá khác...'}
rows={3}
className="w-full px-4 py-3 bg-gray-50 dark:bg-slate-850 border border-gray-200 dark:border-slate-800 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-xs resize-none text-slate-900 dark:text-white"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-6">
<button
type="button"
onClick={() => setIsRatingModalOpen(false)}
className="py-3.5 bg-gray-100 dark:bg-slate-800 hover:bg-gray-250 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-300 font-bold rounded-2xl transition-all active:scale-95 text-xs"
>
{t('cancel') || 'Hủy'}
</button>
<button
type="button"
onClick={handleSubmitRating}
disabled={isSubmittingRating}
className="py-3.5 bg-amber-500 hover:bg-amber-600 disabled:opacity-50 text-white font-bold rounded-2xl shadow-lg transition-all active:scale-95 text-xs flex items-center justify-center gap-1.5"
>
{isSubmittingRating ? (
<Loader2 className="w-4.5 h-4.5 animate-spin" />
) : (
<>
<span>{t('save') || 'Gửi đánh giá'}</span>
</>
)}
</button>
</div>
</div>
</div>
)}
<CommentModal
isOpen={isCommentModalOpen}
onClose={() => setIsCommentModalOpen(false)}