142 Commits

Author SHA1 Message Date
3dtours 03ee337e28 xóa các tập tin ảnh up public 2026-06-25 20:42:31 +07:00
3dtours 48ffd7e49b Thêm README.md cho dự án 2026-06-25 20:07:52 +07:00
3dtours 8df94bca26 fix: sửa lỗi hiển thị trên màn hình android 2026-06-25 17:11:19 +07:00
3dtours d2aced396f fix: bản đồ xoay mượt trên Iphone 2026-06-25 16:48:26 +07:00
3dtours 4e93722ba5 fix: bản đồ không zoom hay thu nhỏ được, không hiển thị nút vị trí của tôi trên bản đồ 2026-06-25 15:57:25 +07:00
3dtours 33a5996bee fix: Ngăn bản đồ tự động reset về trung tâm khi người dùng đã tương tác
- TourDetailPage: MapTourBounds chỉ fitBounds 1 lần cho mỗi lần dữ liệu lộ trình thay đổi thực sự (theo locKey). Nếu người dùng đã pan/zoom rồi thì không refit khi data reload.
- TourNavigationPage: FitBounds chỉ chạy khi người dùng CHƯA tương tác (hasInteractedRef). Sau khi người dùng pan/zoom/drag, bản đồ giữ nguyên vị trí.
- Cả hai bản đồ đều tôn trọng vị trí người dùng đã chọn, không reset về mặc định khi có thay đổi dữ liệu phụ.
2026-06-25 12:28:37 +07:00
3dtours 90475d4130 fix: Ngăn bản đồ ExploreMap tự động nhảy về vị trí người dùng khi thay đổi zoom trên mobile
- Tách userPos (dùng cho marker) và mapCenter (dùng cho center bản đồ) thành 2 state riêng biệt
- MapContainer dùng center={mapCenter} cố định thay vì center={userPos} bị GPS thay đổi liên tục
- Xóa component RecenterMap tự động re-center khi userPos thay đổi
- Thêm nút 'Vị trí của tôi' để người dùng chủ động nhấn mới nhảy bản đồ đến vị trí của mình
- requestGpsPosition cập nhật cả mapCenter lẫn store để map thực sự nhảy khi người dùng chủ động yêu cầu
- Các thao tác khác như click gợi ý tìm kiếm, click ảnh trên bản đồ cũng cập nhật mapCenter để map nhảy đúng vị trí
- Áp dụng cho ExploreMap.tsx - bản đồ chính khi người dùng mở app
2026-06-25 11:58:32 +07:00
3dtours 8e984e609b feat: Thêm nút định vị người dùng trên bản đồ điều hướng mobile
- Thêm nút 'Vị trí của tôi' ở góc dưới bên trái bản đồ
- Khi bật: map tự động center theo vị trí GPS của người dùng (dùng watchPosition)
- Cho phép thu phóng tự do ngay cả khi đang định vị
- Khi người dùng kéo thả bản đồ thủ công: tự động tắt chế độ định vị
- Nút hoạt động độc lập với nút la bàn (compass)
- Sửa import leaflet.css bị lỗi path
2026-06-25 11:34:09 +07:00
3dtours b48502c34a fix: Bản đồ không reset khi người dùng thay đổi zoom trên mobile
- Thêm MapInteractionWatcher lưu zoom/center khi người dùng tương tác
- MapSizeHandler dùng ResizeObserver thay vì setTimeout: chỉ gọi invalidateSize khi container thực sự thay đổi (>2px)
- Sau invalidateSize, tự động restore zoom mà người dùng đã chọn
- canFitRef đảm bảo fitBounds chỉ chạy 1 lần duy nhất khi route vừa load
- Áp dụng cho cả chế độ la bàn bật (xoay theo hướng) và tắt (hướng Bắc)
2026-06-25 11:22:29 +07:00
3dtours 978992057a fix: Luôn giữ kích thước bản đồ người dùng chọn khi tìm đường ngắn nhất
- Thay userInteractedRef bằng canFitRef - chỉ fitBounds đúng 1 lần khi route vừa load
- Sau lần fit đầu, canFitRef = false mãi mãi, không bao giờ reset khi route đổi
- FitBounds chỉ chạy khi canFitRef còn true VÀ compass đang tắt
- MapSizeHandler chỉ gọi invalidateSize() mà không setView để không kích hoạt zoomend/moveend
- Loại bỏ useEffect chết đăng ký zoomend/moveend ở page level (đã có trong MapRefSetter)
- Áp dụng cho cả 2 chế độ: compass bật (xoay theo hướng) và tắt (hướng Bắc)
2026-06-25 11:15:09 +07:00
3dtours ed0fb8dd64 fix: Giữ nguyên zoom/center của người dùng khi bản đồ resize trên mobile
- Thêm userInteractedRef để theo dõi người dùng đã zoom/drag chưa
- FitBounds chỉ chạy khi người dùng chưa tương tác
- MapSizeHandler lưu center/zoom trước khi invalidateSize, khôi phục sau nếu user đã tương tác
- MapRotationHandler xóa transform thay vì set rotate(0) khi compass tắt
- Ngăn map tự động reset về mức zoom ban đầu sau khi người dùng pinch-zoom
2026-06-25 11:08:13 +07:00
3dtours 828bc89890 fix: Sửa lỗi bản đồ mobile chỉ hiển thị kích thước mặc định và không zoom
- Xóa state viewportHeight phụ thuộc documentElement.clientHeight (không ổn định trên mobile)
- Xóa style dvh/-webkit-fill-available gây lỗi trên iOS Safari
- Đổi layout sang flex-1 thuần túy, không tính chiều cao bằng JS
- Xóa CSS transform khi rotation = 0 để tránh lỗi render trên mobile
- Tăng delay invalidateSize lên 100ms để Leaflet đo đúng kích thước container sau layout
2026-06-25 11:03:45 +07:00
3dtours b8bf4f88cc fix: Sửa lỗi bản đồ không full màn hình và không zoom được trên iOS
- Dùng viewportHeight từ JS thay vì CSS dvh để tránh vấn đề iOS Safari
- Thêm MapSizeHandler gọi invalidateSize() để Leaflet tính lại kích thước
- Bật scrollWheelZoom, touchZoom, doubleClickZoom, dragging, inertia
- Đặt maxZoom=20, minZoom=2 rõ ràng
- Loại bỏ max-width/overflow-x trong index.css để không ảnh hưởng trang khác
2026-06-25 10:33:01 +07:00
3dtours f74587376e feat: Tạo trang bản đồ chỉ đường độc lập TourNavigationPage
- Thêm trang TourNavigationPage.tsx hiển thị full màn hình với
  top-bar, bản đồ fullscreen và nút la bàn nổi ở góc dưới bên phải
- Cập nhật ItineraryTimeline.tsx: thay thế LocationNavigationModal
  bằng callback onOpenNavigationPage để chuyển đến trang mới
- Đăng ký trang tourNavigation trong App.tsx với navigationPayload
- Cập nhật TourDetailPage.tsx truyền onOpenNavigationPage xuống
- Thêm INSTALL_MAP.md hướng dẫn thực hiện tính năng
2026-06-25 10:17:23 +07:00
3dtours a135196cb5 fix: tính năng tìm đường ngắn nhất và chỉ đường cho người dùng 2026-06-24 22:05:41 +07:00
3dtours cfdcb573de feat: khi người dùng click vào nút đầu mỗi điểm để tìm đường đi trên bản đồ 2026-06-24 21:53:52 +07:00
3dtours f17215f236 feat: khi người dùng click vào nút Chi tiết hành trình thì sẽ tự cuộn đến mốc thời gian hiện tại 2026-06-24 21:25:04 +07:00
3dtours 5e60614588 feat: khi người dùng click vào nút Chi tiết hành trình thì sẽ dựa vào ngày của hệ thống mà chuyển đến Chặng đó 2026-06-24 21:17:26 +07:00
3dtours e385049652 fix: in đậm cho tiêu đề của cột khi xuất pdf 2026-06-24 20:52:17 +07:00
3dtours ef295e0994 fix: hiển thị ngày ở các chặng 2026-06-24 20:43:49 +07:00
3dtours c3382a422d fix: hiển thị ngày ở các chặng 2026-06-24 20:41:15 +07:00
3dtours 5d47e6d291 fix: lỗi nút xuất PDF và chèn link ở Địa điểm khi xuất PDF 2026-06-24 19:54:14 +07:00
3dtours 957ba6c72c fix: lỗi font Bold của xuất pdf 2026-06-24 18:17:42 +07:00
3dtours 4b551ccc31 fix: thêm múi giờ vào docker-compose.prod.yml để sửa lỗi hiển thị sai giờ 2026-06-24 17:45:02 +07:00
3dtours 9dc1faee6f fix: lưu ghi chú ở địa điểm vào ghi chú 2026-06-24 17:05:21 +07:00
3dtours b998833371 fix: thay đổi màu theme tổi của timeline hành trình 2026-06-24 10:40:59 +07:00
3dtours 13cd68707e fix: sửa lỗi hiển thị trong itineraryTimeline và MemberDashboard 2026-06-24 09:56:05 +07:00
3dtours f355b9471a fix: chọn camera hay thư viện 2026-06-23 18:42:56 +07:00
3dtours a64af5f9cf feat: apply theme colors to all pages and modals - implement light/dark theme CSS variables across UI components 2026-06-23 18:28:05 +07:00
3dtours 7182391241 fix: paste trực tiếp tọa độ vào kinh độ vĩ độ 2026-06-23 17:50:05 +07:00
3dtours 28ea9abccd fix: tạo tags cho ảnh public của guest 2026-06-23 16:01:46 +07:00
3dtours 9f41400bd8 fix: hiển thị 2 nút google sheets và pdf 2026-06-23 14:58:07 +07:00
3dtours 78c5754655 fix: lỗi xem ảnh trong Tour 2026-06-23 12:46:59 +07:00
3dtours 5145835c8a fix: gửi vị trí khẩn cấp trong Dashboard 2026-06-23 12:44:38 +07:00
3dtours 6b15e7ff02 fix: lỗi hiển thị trong itineraryTimeline 2026-06-23 11:40:41 +07:00
3dtours a65be48d36 fix: lướt xem ảnh ở màn hình index của người dùng public 2026-06-22 22:16:51 +07:00
3dtours ac91f6e4c8 fix: admin login khi close tự động logout 2026-06-22 20:22:13 +07:00
3dtours 09b1ee882d fix: login với Google Oauth lỗi token invite 2026-06-22 20:08:11 +07:00
3dtours acf867a375 fix: guest hiển thị lời mời này không tồn tại 2026-06-22 16:54:32 +07:00
3dtours dd2635a44d fix: guest reload page to accessed MemberDashboard 2 2026-06-22 15:00:42 +07:00
3dtours 29c19c6372 fix: guest reload page to accessed MemberDashboard 2026-06-22 12:45:29 +07:00
3dtours 01d6d7439f fix: admin empty thùng rác 2026-06-22 12:28:21 +07:00
3dtours 860395cb14 fix: guest and admin logic 2026-06-22 11:41:34 +07:00
3dtours b1a539235b fix: lỗi hiển thị ở frontend 2026-06-21 21:53:14 +07:00
3dtours 403c169ddd feat: tính năng chia sẻ khẩn cấp 2026-06-21 12:52:47 +07:00
3dtours 8d79cd76f6 feat: tíng năng trò chuyện giữa các thành viên và tin nhắn trực tiếp cho bạn bè 2026-06-21 09:46:37 +07:00
3dtours 392a4d4766 fix: khách mời join vào tour gán với tên thêm thủ công 2026-06-21 07:37:20 +07:00
3dtours 55fba75fda fix: khách mời join vào tour thông qua link mời 2026-06-21 07:11:26 +07:00
3dtours 36157bd53b bugs: lỗi khi người dùng link để tham gia tour nhưng không xuất hiện trong danh sách thành viên 2026-06-20 20:59:01 +07:00
3dtours 52706cab7d fix: thêm thành viên ngoài hệ thống vào Tour 2026-06-20 17:56:56 +07:00
3dtours ae97f061e8 fix: sửa lỗi hiển thị logo trên trang pdf 2026-06-20 17:02:26 +07:00
3dtours e34d197dd0 fix: sửa lỗi hiển thị thumbnail khi chia sẻ trên facebook 2026-06-20 14:44:31 +07:00
3dtours fdf41e05fc fix: sửa lỗi hiển thị trên browser của điện thoại 2026-06-20 12:19:30 +07:00
3dtours ae74035ad5 fix: cho phép người đã tải ảnh lên được quyền chỉnh sửa 2026-06-20 08:27:58 +07:00
3dtours ac11bb56db fix: bố cục lại cách hiển thị thông tin ảnh 2026-06-20 08:04:12 +07:00
3dtours 5081546cdf feat: cho phép người dùng chọn tọa độ trên bản đồ khi sửa ảnh 2026-06-20 07:40:50 +07:00
3dtours 9da8a9494d feat: cho phép người dùng chỉnh sửa thông tin ảnh đã upload 2026-06-20 07:37:01 +07:00
3dtours 4373ed684a fix: ảnh phải giữ nguyên hướng chụp 2026-06-20 07:28:28 +07:00
3dtours def0e0d0f9 fix: sửa lỗi các nút bị che dưới thanh địa chỉ của chrome, hỗ trợ iphone upload ảnh heic 2026-06-20 07:05:25 +07:00
3dtours 36e8658dad fix: sửa lỗi iphone ko thể upload 2026-06-19 22:49:51 +07:00
3dtours 6c1b77d7d2 feat: tải ảnh lên bằng tài khoản public và cho phép bình luận 2026-06-19 22:38:16 +07:00
3dtours 4640526e4b fix: thay đổi ảnh nền trang chủ 2026-06-19 12:38:17 +07:00
3dtours c7692ab9b6 fix: thay đổi giao diện trang chủ 2026-06-19 12:00:19 +07:00
3dtours 36fbbb9386 fix: remove leaflet liên kết trên bản đồ 2026-06-19 10:34:03 +07:00
3dtours ef11309399 feat: chuẩn bị backend cho tính năng anonymous user upload ảnh không cần đăng nhập 2026-06-19 08:46:59 +07:00
3dtours 6f2dd0fc01 fix: lỗi đọc nhầm .env ở thư mục backend 2026-06-19 08:13:35 +07:00
3dtours 623f0164c8 feat: cài đặt tính năng gửi mã OTP qua mail xác thực 2026-06-18 23:00:12 +07:00
3dtours 88b47099b1 feat: cài đặt tính năng gửi mã OTP qua mail xác thực 2026-06-18 23:00:03 +07:00
3dtours 659d2a0840 fix: hiển thị market chỉ hướng người dùng di chuyển trên iOS 2026-06-18 21:24:08 +07:00
3dtours 68950dba10 fix: hiển thị market chỉ hướng người dùng di chuyển 2026-06-18 10:51:47 +07:00
3dtours 3e215ae8e8 fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ đã mượt 2026-06-17 22:41:45 +07:00
3dtours e7b8c672e0 fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ đã mượt nhưng đến góc là xoay tròn 2026-06-17 22:36:05 +07:00
3dtours 2462850e2e fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn xoay tròn 2026-06-17 22:23:01 +07:00
3dtours abb61f65cd feat: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn lag 2026-06-17 22:20:57 +07:00
3dtours da32d59161 feat: nhấn nút toggle status full bản đồ, nhấn nút bản đồ thì giao diện cũ 2026-06-17 21:53:46 +07:00
3dtours 40b1c4f2ab feat: nhấn nút toggle status bản đồ sẽ hiển thị toàn màn hình 2026-06-17 21:41:43 +07:00
3dtours 55de887f87 feat: nhấn nút toggle status để chuyển đến bản đồ di chuyển 2026-06-17 21:30:41 +07:00
3dtours ffe3cb6ecd fix: tạm dừng thuật toán tìm đường đi 2026-06-17 21:16:31 +07:00
3dtours 69c67a1636 fix: sửa lại các tuyến đường đề xuất trên bản đồ 2026-06-17 20:49:30 +07:00
3dtours f181009fa5 fix: sửa lại các nút hiển thị trên màn hình gom lại thành 1 nút 2026-06-17 20:08:28 +07:00
3dtours b6551da147 fix: sửa lại cho phép chỉnh sửa thời gian bắt đầu và kết thúc của điểm xuất phát và điểm kết thúc 2026-06-17 19:46:55 +07:00
3dtours 2afb2971da fix: sửa lại hiển thị của các nút trên bản đồ 2026-06-17 19:37:29 +07:00
3dtours 14ffbb8657 fix: xóa bảng hiển thị mẹo trên bản đồ 2026-06-17 19:34:13 +07:00
3dtours fbe5ef873b feat: thêm tính năng hiển thị tooltip mẹo nhấn chuột phải 2026-06-17 19:30:58 +07:00
3dtours d6ee1dde74 feat: thêm tính năng nút la bàn để xoay map theo hướng Bắc hoặc theo hướng người dùng di chuyển 2026-06-17 19:19:29 +07:00
3dtours 288b40ad12 feat: thêm tính năng tìm tôi để tự động di chuyển bản đồ về vị trí của người dùng 2026-06-17 19:14:18 +07:00
3dtours 3a3296c340 feat: thêm tính năng hiển thị vị trí người dùng trên bản đồ 2026-06-17 18:27:52 +07:00
3dtours e664a3797e feat: thêm tính năng đường dành cho xe máy và đi bộ 2026-06-17 18:00:45 +07:00
3dtours 36418673db feat: thêm tính năng vẽ đường di chuyển trên bản đồ 2026-06-17 17:44:51 +07:00
3dtours 98e4a4a340 fix: lỗi không xuất hiện nút sửa cho điểm bắt đầu và điểm kết thúc 2026-06-17 17:39:53 +07:00
3dtours bfbbb66747 fix: lỗi bong bóng Tour vẫn xuất hiện và điểm kết thúc 2026-06-17 17:33:47 +07:00
3dtours 1933b58f84 fix: lỗi bong bóng Tour vẫn xuất hiện và điểm bắt đầu là điểm xuất phát 2026-06-17 17:29:40 +07:00
3dtours 05dc80bb6d fix: lỗi bong bóng Tour vẫn hiển thị sau khi xóa 2026-06-17 17:15:11 +07:00
3dtours 4d63bff214 feat: thêm nút xóa tour trong phần cài đặt của Tour 2026-06-17 15:48:00 +07:00
3dtours c5474f1ff6 fix: sửa tính năng hiển thị quãng đường tại điểm đến kế tiếp 2026-06-17 15:37:18 +07:00
3dtours 7ad785fed9 feat: thêm tính năng ghi note cho Tour 2026-06-17 12:28:24 +07:00
3dtours e66f242c2c fix: click vào nút bình luận không load dược page 2026-06-16 21:44:49 +07:00
3dtours 6026c2227a fix: UI của trang MyPhotoPage lỗi 2026-06-16 21:25:08 +07:00
3dtours 1eed9b00de feat: xem ảnh theo cấu trúc của lightroom 2026-06-16 20:38:40 +07:00
3dtours 1709638bfd optimize: xác nhận redis đã nén dữ liệu 2026-06-16 20:22:53 +07:00
3dtours 37b5d14d7e optimize: sử dụng redis để cache cho các response từ server 2026-06-16 20:17:17 +07:00
3dtours 2fabdb79df fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:55 +07:00
3dtours 00554224a1 fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:24 +07:00
3dtours 88b2182789 feat: thêm chức năng upload ảnh 2026-06-16 17:14:22 +07:00
3dtours 989d60643b feat: thêm nút tìm kiếm địa điểm cho phần tạo tour, phần thêm địa điểm 2026-06-16 15:45:26 +07:00
3dtours 1772ced959 feat: nút lọc filter tags dropdown 2026-06-16 15:35:45 +07:00
3dtours c005009da2 feat: Chuyển khám phá địa điểm thành box tìm kiếm địa điểm trên bản đồ 2026-06-16 15:28:10 +07:00
3dtours bfd18e05dd feat: tạo hook useNotification.tsx để dùng chung toàn hệ thống 2026-06-16 12:41:00 +07:00
3dtours 29d39ae7b0 feat: tạo hook useConfirm.tsx để dùng chung toàn hệ thống 2026-06-16 12:29:09 +07:00
3dtours 047170d4be feat: cho phép người dùng và người quản lí xóa bình luận 2026-06-16 12:21:44 +07:00
3dtours fc96ee9eb8 fix: tìm kiếm địa điểm với cách gõ tên trên bản đồ 2026-06-16 12:15:01 +07:00
3dtours 0a584a13c7 feat: tìm kiếm địa điểm trên bản đồ 2026-06-16 11:37:21 +07:00
3dtours f78c72ad60 fix: hiển thị mô tả và tên của hành trình có thêm tags 2026-06-16 11:21:43 +07:00
3dtours 8cca4a83ca fix: tags không có trong csdl gây nên lỗi crash backend 2026-06-16 11:15:39 +07:00
3dtours bc49e081c6 feat: cho phép lấy thời gian và địa điểm hiện tại để gán cho điểm 2026-06-16 10:55:18 +07:00
3dtours 9e26aabce6 Sửa lỗi hiển thị chi phí ở điểm của chặng 2026-06-16 10:36:38 +07:00
3dtours 3e1a5db1a6 Sửa lỗi hiển thị bảng liệt kê chi phí 2026-06-16 10:28:33 +07:00
3dtours baa83aad8a Sửa lỗi người nhận được line xem được nội dung Tour nhưng không xem được bình luận 2026-06-16 09:56:59 +07:00
3dtours 302a82e887 Sửa lỗi người nhận được liên kết chia sẻ không xem được nội dung Tour 2026-06-16 09:54:32 +07:00
3dtours 63da413b3c Sửa lỗi OWNER, MANAGER không thể chia sẻ link 2026-06-16 09:33:15 +07:00
3dtours 02c493f7e0 Thêm tính năng click chuột phải vào bong bóng để lấy link chia sẻ 2026-06-16 08:46:45 +07:00
3dtours b939fc959d Thêm tính năng hiển thị tooltip trên bong bóng Tour 2026-06-16 08:35:48 +07:00
3dtours 5464d90948 Thêm tính năng bình luận ở mỗi điểm của chặng 2026-06-16 08:27:54 +07:00
3dtours c5530f36df feat: cập nhật giao diện tour hiển thị mô tả trên banner 2026-06-15 21:28:41 +07:00
3dtours dcaf71032c Cài đặt tính năng thay đổi tên và mô tả tour 2026-06-15 21:22:05 +07:00
3dtours aec9112064 Cài đặt tính năng người quản lí cập nhật số người tham gia 2026-06-15 20:55:36 +07:00
3dtours 8dac79fc4b Cài đặt tính năng người quản lí có thể khai báo số lượng người tham gia 2026-06-15 20:52:43 +07:00
3dtours 75cf96898c Cài đặt tính năng in bảng thống kê ra pdf 2026-06-15 20:43:32 +07:00
3dtours f52580717f Cài đặt tính năng hiển thị chi phí 2026-06-15 20:20:34 +07:00
3dtours 879f20985c Chỉnh sửa ngày của hành trình là ngày của chặng đầu tiên và chặng hoàn thành 2026-06-15 19:57:54 +07:00
3dtours 4edfd0eb59 Sửa lỗi chỉnh sửa tiêu đề chặng và ngày giờ của chặng 2026-06-15 19:53:58 +07:00
3dtours 55f0a8e775 Sửa lỗi không thể khai báo số lượng chặng 2026-06-15 19:42:45 +07:00
3dtours 7812d1395b Sửa lỗi thành viên có thể gửi lời mời các users khác 2026-06-15 19:38:07 +07:00
3dtours c9ef98b2ff Sửa lỗi không truy cập IP từ ngoài 2026-06-15 19:31:03 +07:00
3dtours 71f4aa9ecd Sửa lỗi frontend không load dược danh sách thêm thành viên 2026-06-15 18:27:54 +07:00
3dtours 34e2af8825 Sửa lỗi frontend không kết nối với backend 2026-06-15 18:25:37 +07:00
3dtours 4716b841dc Sửa lỗi tái cấu trúc thư mục và khai báo import 2026-06-15 16:31:28 +07:00
3dtours 967f6b4f6a Phục hồi lại và chuẩn bị tái cấu trúc thư mục 2026-06-15 15:00:37 +07:00
3dtours c6341aa12f Thêm tính năng owner có thể thêm thành viên, member thêm thành viên sẽ pending 2026-06-14 22:14:40 +07:00
3dtours 7cc724a133 Thêm tính năng sửa tính năng pending bị lỗi 2026-06-14 21:07:21 +07:00
3dtours 8ff09eeeaf Thêm tính năng pending khi một thành viên thêm một người khác vào 2026-06-14 20:22:37 +07:00
3dtours 914a9cf243 Thêm tính năng thêm hoặc xóa thành viên ngay tại trang tạo tour 2026-06-14 18:39:37 +07:00
198 changed files with 38995 additions and 6253 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
server/node_modules/
# Bỏ qua cấu hình hệ thống và Git
.git/
.idea/
.vscode/
# Bỏ qua các file log và build
*.log
dist/
build/
out/
+8
View File
@@ -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/
+2
View File
@@ -1,2 +1,4 @@
.env
node_modules
server/dist
dist
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://app.kilo.ai/config.json"
}
+2 -1
View File
@@ -1,3 +1,4 @@
{
"continue.enableConsole": true
"continue.enableConsole": true,
"remote.autoForwardPortsFallback": 0
}
-312
View File
@@ -1,312 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet';
// Cấu hình Icon mặc định để tránh crash Marker trong Modal
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
// Component Helper xử lý việc chọn vị trí trên mini map bằng chuột phải
const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, center: [number, number] }) => {
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const map = useMap();
useMapEvents({
contextmenu: (e) => {
L.DomEvent.preventDefault(e.originalEvent);
L.DomEvent.stopPropagation(e.originalEvent);
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
},
click: () => setMenuPos(null),
dragstart: () => setMenuPos(null),
});
useEffect(() => {
if (menuPos && menuRef.current) {
L.DomEvent.disableClickPropagation(menuRef.current);
}
}, [menuPos]);
useEffect(() => {
map.setView(center, map.getZoom());
}, [center]);
return (
<>
{menuPos && (
<div
ref={menuRef}
className="absolute z-[3000] bg-white rounded-xl shadow-xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
style={{ top: menuPos.y, left: menuPos.x }}
>
<button
type="button"
onClick={() => { onPick(menuPos.latlng); setMenuPos(null); }}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-xs font-bold text-blue-600 flex items-center gap-2"
>
<MapPin className="w-3 h-3" /> Thêm vào chặng hiện tại
</button>
</div>
)}
</>
);
};
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => {
const [formData, setFormData] = useState({
name: '',
address: '',
latitude: 10.7769,
longitude: 106.7009,
type: 'VISIT',
legId: '',
note: '',
expenseAmount: '',
expenseCategory: 'OTHER',
expenseDescription: '',
expenseNote: '',
paidById: '',
plannedStart: '',
plannedEnd: ''
});
const [isLoading, setIsLoading] = useState(false);
// 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 } = useTourStore();
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
useEffect(() => {
if (isOpen) {
if (editingLocation) {
const leg = legs.find(l => l.id === editingLocation.legId);
const expense = leg?.expenses?.find((e: any) => e.locationId === editingLocation.id);
setFormData({
name: editingLocation.name || '',
address: editingLocation.address || '',
latitude: editingLocation.latitude,
longitude: editingLocation.longitude,
type: editingLocation.type || 'VISIT',
legId: editingLocation.legId || '',
note: editingLocation.note || '',
expenseAmount: expense?.amount?.toString() || '',
expenseCategory: expense?.category || 'OTHER',
expenseDescription: expense?.description || '',
expenseNote: expense?.note || '',
paidById: expense?.paidById || '',
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '',
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
});
} else {
setFormData(prev => ({
...prev,
name: '', address: '',
legId: initialLegId || (legs.length > 0 ? legs[0].id : ''),
note: '', expenseAmount: '', expenseCategory: 'OTHER',
expenseDescription: '', expenseNote: '', paidById: '',
plannedStart: '', plannedEnd: ''
}));
}
}
}, [initialLegId, editingLocation, isOpen]);
useEffect(() => {
if (isOpen && !formData.name) {
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
}
}, [isOpen, mapCenter]);
// 2. Thực hiện các tính toán và hàm xử lý
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
const targetLeg = legs.find(l => l.id === currentLegId);
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
const handlePickLocation = async (latlng: L.LatLng) => {
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
// Tự động lấy tên địa điểm từ tọa độ vừa chọn
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
const data = await res.json();
const addr = data.address;
const detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
addr.shop || addr.office || addr.leisure || addr.attraction ||
addr.road || addr.neighbourhood || addr.suburb ||
data.display_name?.split(',')[0] || "";
setFormData(prev => ({ ...prev, name: detectedName || prev.name, address: data.display_name || prev.address }));
} catch (e) {}
};
// 3. Early return phải nằm SAU tất cả các khai báo Hook
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const payload: any = {
...formData,
legId: currentLegId,
latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any),
};
if (editingLocation) {
await updateLocation(editingLocation.id, payload);
} else {
await addLocation(tourId, payload);
}
onClose();
} catch (error) {
alert('Lỗi khi lưu địa điểm');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<MapIcon className="w-6 h-6 text-blue-600" /> {titleText}
</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
{/* Mini Map Picker */}
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group">
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full">
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<Marker position={[formData.latitude, formData.longitude]} />
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} />
</MapContainer>
<div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100">
CHUỘT PHẢI Đ CHỌN VỊ TRÍ
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên đa điểm</label>
<input required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Đa chỉ</label>
<input className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Ghi chú đa điểm / Dịch vụ sử dụng</label>
<textarea className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none resize-none" rows={2}
placeholder="Ví dụ: Ăn trưa tại quán X, thuê hướng dẫn viên..."
value={formData.note} onChange={e => setFormData({...formData, note: e.target.value})} />
</div>
<div className="bg-blue-50/60 border border-blue-100 rounded-2xl p-4 space-y-3">
<p className="text-xs font-black text-blue-500 uppercase tracking-widest">Chi phí nhanh tại điểm này</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
placeholder="0"
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={formData.expenseCategory} onChange={e => setFormData({...formData, expenseCategory: e.target.value})}>
<option value="FOOD">Ăn uống</option>
<option value="TRANSPORT">Di chuyển</option>
<option value="ACCOMMODATION">Chỗ </option>
<option value="TICKET"> tham quan</option>
<option value="OTHER">Khác</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Dịch vụ / tả</label>
<input className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
placeholder="Ví dụ: Ăn trưa, taxi, vé..."
value={formData.expenseDescription} onChange={e => setFormData({...formData, expenseDescription: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Ghi chú chi phí</label>
<textarea className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none resize-none text-sm" rows={2}
placeholder="Ghi chú thêm..."
value={formData.expenseNote} onChange={e => setFormData({...formData, expenseNote: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Thành viên đã thanh toán</label>
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
<option value="">-- Chọn người thanh toán --</option>
{currentTour?.participants?.map((p: any) => {
const name = p.user?.name;
const email = p.user?.email;
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
return (
<option key={p.userId} value={p.userId}>{label || p.userId}</option>
);
})}
</select>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Gán vào chặng</label>
<select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}>
{legs.map(leg => (
<option key={leg.id} value={leg.id}>Chặng {leg.sequence}: {leg.note || 'Không có tên'}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1"> đ</label>
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.latitude} onChange={e => setFormData({...formData, latitude: e.target.value as any})} />
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Kinh đ</label>
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.longitude} onChange={e => setFormData({...formData, longitude: e.target.value as any})} />
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Loại</label>
<select className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.type} onChange={e => setFormData({...formData, type: e.target.value as any})}>
<option value="VISIT">Tham quan</option>
<option value="EAT">Ăn uống</option>
<option value="REST">Nghỉ ngơi</option>
<option value="MOVE">Di chuyển</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label>
<input type="datetime-local" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.plannedStart} onChange={e => setFormData({...formData, plannedStart: e.target.value})} />
</div>
</div>
<button disabled={isLoading} className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl flex items-center justify-center gap-2 mt-4">
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : buttonText}
</button>
</form>
</div>
</div>
);
};
-279
View File
@@ -1,279 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { X, Search, UserPlus, Loader2, Shield, Trash2 } from 'lucide-react';
interface AddMemberModalProps {
isOpen: boolean;
onClose: () => void;
tourId: string;
participants?: Array<{ userId: string; role: string; user?: { id: string; name: string; email: string } }>;
onRemoveMember?: (userId: string) => Promise<void>;
onMemberAdded?: () => void;
}
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], onRemoveMember, onMemberAdded }) => {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [role, setRole] = useState<'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY'>('MEMBER');
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 participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
const fetchUsers = async () => {
setLoading(true);
setFetchError('');
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(query)}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) throw new Error('Không thể tải danh sách người dùng');
const data = await res.json();
setUsers(Array.isArray(data) ? data : []);
} catch (err: any) {
setFetchError(err.message || 'Không thể tải danh sách người dùng');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
fetchUsers();
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
setQuery('');
setSelectedUser(null);
setRole('MEMBER');
setFetchError('');
setSubmitError('');
}
}, [isOpen]);
const handleRemove = async (userId: string, memberName: string) => {
if (!onRemoveMember) return;
setConfirmTarget({ userId, name: memberName });
setIsConfirmOpen(true);
};
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);
}
};
const handleAdd = async () => {
if (!selectedUser) return;
setSubmitting(true);
setSubmitError('');
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ userId: selectedUser, role }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || 'Thêm thành viên thất bại');
}
onMemberAdded?.();
onClose();
} catch (err: any) {
setSubmitError(err.message || 'Thêm thành viên thất bại');
} finally {
setSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<UserPlus className="w-5 h-5 text-blue-600" /> Thêm thành viên
</h2>
<p className="text-xs text-gray-500">Chọn người dùng phân quyền cho tour này.</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<div className="p-5 space-y-4">
<div>
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p>
<div className="flex flex-wrap gap-3">
{participants.map((p) => {
const rawToken = localStorage.getItem('token');
let currentUserId: string | null = null;
try {
const payload = JSON.parse(atob((rawToken || '').split('.')[1]));
currentUserId = payload.sub;
} catch {
currentUserId = null;
}
const isCurrentUser = currentUserId && p.userId === currentUserId;
const isOwner = p.role === 'OWNER';
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
return (
<div key={p.userId} className="flex flex-col items-center gap-1">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
{p.user?.name?.charAt(0) || '?'}
</div>
{canRemove && (
<button
onClick={() => handleRemove(p.userId, p.user?.name || p.userId)}
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
aria-label="Remove item"
>
<Trash2 size={10} />
</button>
)}
</div>
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{p.user?.name || p.userId}</span>
</div>
);
})}
{participants.length === 0 && (
<span className="text-xs text-gray-400">Chưa thành viên nào</span>
)}
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
className="w-full pl-9 pr-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl outline-none text-sm"
placeholder="Tìm theo tên hoặc email..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onBlur={fetchUsers}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền</label>
<select
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={role}
onChange={(e) => setRole(e.target.value as any)}
>
<option value="OWNER">OWNER</option>
<option value="MANAGER">MANAGER</option>
<option value="MEMBER">MEMBER</option>
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE</option>
<option value="VIEWER_ONLY">VIEWER_ONLY</option>
</select>
</div>
<div className="space-y-2">
{fetchError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError}
</div>
)}
{submitError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{submitError}
</div>
)}
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
{visibleUsers.map((u) => {
const isSelected = selectedUser === u.id;
return (
<button
key={u.id}
onClick={() => setSelectedUser(u.id)}
className={`w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${
isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'
}`}
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || '?'}
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
<div className="text-[11px] text-gray-500">{u.email}</div>
<div className="text-[11px] text-gray-400">{u.phone || ''} {u.address ? `${u.address}` : ''}</div>
</div>
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100">USER</span>
)}
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
</div>
</button>
);
})}
{!loading && visibleUsers.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không tìm thấy người dùng phù hợp</div>
)}
</div>
)}
</div>
</div>
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<button
disabled={!selectedUser || submitting}
onClick={handleAdd}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all"
>
{submitting ? 'Đang thêm...' : 'Thêm vào tour'}
</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 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>
);
};
-91
View File
@@ -1,91 +0,0 @@
import React, { useState, useEffect } from 'react';
import { LandingPage } from './LandingPage.js';
import { TourDetailPage } from './TourDetailPage.js';
import { ExploreMap } from './ExploreMap.js';
import { SignupPage } from './SignupPage.js';
import { useTourStore } from './useTourStore.js';
const App = () => {
type View = 'landing' | 'explore' | 'detail' | 'signup';
const [view, setView] = useState<View>('landing');
const [isInitialSetup, setIsInitialSetup] = useState(false);
const [user, setUser] = useState<any>(null);
const [isUserLoaded, setIsUserLoaded] = useState(false); // Trạng thái để biết user đã được load từ localStorage chưa
const fetchTour = useTourStore(state => state.fetchTour);
useEffect(() => {
// Tự động xác định địa chỉ IP của Backend dựa trên hostname hiện tại
const API_BASE = `http://${window.location.hostname}:3001`;
// Khôi phục phiên đăng nhập từ localStorage
const savedUser = localStorage.getItem('user');
if (savedUser) {
const parsedUser = JSON.parse(savedUser);
setUser(parsedUser);
}
setIsUserLoaded(true); // Đánh dấu user đã được load
// Kiểm tra xem hệ thống đã được cài đặt chưa
fetch(`${API_BASE}/api/v1/auth/status`)
.then(res => res.ok ? res.json() : Promise.reject())
.then(data => setIsInitialSetup(!!data.isInitialSetup))
.catch(() => setIsInitialSetup(false));
}, []); // Chạy một lần khi component mount
// Effect để xử lý chuyển hướng nếu user đã đăng nhập và đang ở trang landing
useEffect(() => {
if (isUserLoaded && user && view === 'landing') {
setView('explore');
}
}, [isUserLoaded, user, view]);
const handleLoginSuccess = (userData: any) => {
setUser(userData);
};
const handleLogout = () => {
localStorage.removeItem('user');
localStorage.removeItem('token');
setUser(null);
setView('landing');
};
return (
<div className="app-container">
{view === 'landing' && (
<LandingPage
isInitialSetup={isInitialSetup}
onContinue={() => setView('explore')}
onGoToSignup={() => setView('signup')}
onGoToMap={() => setView('explore')}
onLoginSuccess={handleLoginSuccess}
/>
)}
{view === 'signup' && (
<SignupPage
onBack={() => setView('landing')}
onSuccess={() => setView('landing')}
/>
)}
{view === 'explore' && (
<ExploreMap
onBack={() => setView('landing')}
onLogout={user ? handleLogout : undefined}
user={user}
onViewTour={(id) => {
fetchTour(id);
setView('detail');
}}
/>
)}
{view === 'detail' && (
<TourDetailPage onBack={() => setView('explore')} />
)}
</div>
);
};
export default App;
-166
View File
@@ -1,166 +0,0 @@
import React, { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
const [title, setTitle] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [isLoading, setIsLoading] = useState(false);
const createTour = useTourStore((state) => state.createTour);
const [members, setMembers] = useState<any[]>([]);
const [query, setQuery] = useState('');
const [results, setResults] = useState<any[]>([]);
const [error, setError] = useState('');
if (!isOpen) return null;
const searchUsers = async (value: string) => {
setQuery(value);
if (!value.trim()) {
setResults([]);
return;
}
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(value)}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
if (!res.ok) throw new Error('Không thể tải người dùng');
const data = await res.json();
setResults(Array.isArray(data) ? data : []);
} catch (e: any) {
setResults([]);
setError(e.message || 'Không thể tải người dùng');
}
};
const confirmAddMember = (user: any) => {
setMembers((prev) => (prev.some((m) => m.id === user.id) ? prev : [...prev, user]));
setQuery('');
setResults([]);
setError('');
};
const removeMember = (userId: string) => {
setMembers((prev) => prev.filter((m) => m.id !== userId));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const memberIds = members.map((m) => m.id);
const tour = await createTour({ title, startDate, endDate, memberIds });
onSuccess(tour);
onClose();
} catch (e: any) {
setError(e.message || 'Lỗi khi tạo tour');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors"></button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
<input
required
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="VD: Khám phá Đà Lạt"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label>
<input
type="date"
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label>
<input
type="date"
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label>
<div className="flex flex-wrap gap-3">
{members.map((m) => (
<div key={m.id} className="relative">
<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 text-sm overflow-hidden">
{m.name}
</div>
<button
type="button"
onClick={() => removeMember(m.id)}
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors"
aria-label="Remove item"
>
<Trash2 size={12} />
</button>
<div className="text-[10px] text-center mt-1 max-w-[70px] truncate">{m.name}</div>
</div>
))}
<div className="relative">
<input
className="w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none"
placeholder="Tìm email..."
value={query}
onChange={(e) => searchUsers(e.target.value)}
/>
{results.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto">
{results.map((u) => (
<button
key={u.id}
type="button"
onClick={() => confirmAddMember(u)}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50"
>
<span className="font-bold text-gray-900">{u.name}</span>
<span className="block text-xs text-gray-500">{u.email}</span>
</button>
))}
</div>
)}
</div>
</div>
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2"
>
{isLoading ? 'Đang tạo...' : 'Xác nhận tạo Tour'}
</button>
</form>
</div>
</div>
);
};
+126
View File
@@ -0,0 +1,126 @@
# To AI Agent: Implement Smart Date-Based Accordion Auto-Expansion and Smooth Viewport Auto-Scroll
## 1. Context & UI/UX Requirements
We are enhancing the routing UX between `MemberDashboard.tsx` and `ItineraryTimeline.tsx` based on `image_cc31a4.png`.
### Functional Specifications:
1. **Date-Matching Evaluation:** When a user clicks "Chi tiết hành trình" on a tour card, calculate if the current user system local date falls inside any Stage/Leg timeline block.
2. **Auto-Expansion State:** On timeline page load, the matched Leg accordion must be expanded by default (`expandedStageId === leg.id`).
3. **Smart Auto-Scroll Layout:** The viewport must smoothly auto-scroll so that the expanded Leg's title bar lands **exactly below the sticky navigation tab bar** ("Lộ trình", "Chi phí", etc.), making it fully visible at the top of the viewport without layout clipping.
---
## 2. Technical Architecture & Layout Constraints
- **The Sticky Header Challenge:** Since the top navigation tab bar uses sticky/fixed positioning, a naive `scrollIntoView({ block: 'start' })` will cause the Leg's title to crawl *underneath* the tabs, hiding it.
- **The Solution:** We will inject a dynamic CSS scroll-margin-top parameter (`scroll-mt-[70px]` or matching header height) on each Leg container, and trigger a minor delayed layout effect pool using a React `setTimeout` to wait for the DOM accordion expansion reflow before pulling the scroll trigger.
---
## 3. Code Refactoring Blueprint
### Step 1: Update Navigation Payload Handler in Dashboard Component
Ensure the `MemberDashboard.tsx` accurately packages the matched target identifier into the client route state bucket:
```typescript
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
const handleNavigateToItinerary = (tour: any) => {
let targetLegId = null;
if (tour?.legs && tour.legs.length > 0) {
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const leg of tour.legs) {
const rawStart = leg.startDate || leg.plannedStart || leg.date;
const rawEnd = leg.endDate || leg.plannedEnd || leg.date;
if (rawStart) {
const startBound = new Date(rawStart);
startBound.setHours(0, 0, 0, 0);
const endBound = rawEnd ? new Date(rawEnd) : new Date(rawStart);
endBound.setHours(23, 59, 59, 999);
if (today >= startBound && today <= endBound) {
targetLegId = leg.id;
break;
}
}
}
if (!targetLegId) {
targetLegId = tour.legs[0].id; // Fallback to Chặng 1 if out of tour bounds
}
}
navigate(`/tour/${tour.id}`, {
state: { defaultExpandedLegId: targetLegId, shouldScrollToTarget: true }
});
};
### Step 2: Inject Safe Identifiers and Scroll Margin inside ItineraryTimeline
Locate the top-level outer container div of each Leg item inside the timeline loop (currentTour.legs.map). Assign a unique id and a scroll margin class utility:
{currentTour.legs.map((leg: any, legIdx: number) => (
<div
key={leg.id}
id={`leg-anchor-node-${leg.id}`}
/* CRITICAL: scroll-mt-[70px] leaves a 70px buffer space at the top.
Adjust '70px' to match the exact height of your white Tour Navigation Tabs bar!
*/
className="scroll-mt-[70px] transition-all w-full mb-4"
>
{/* Accordion Header Title ("Chặng 2: Dạo chơi ở xứ hoa vàng...") */}
<div className="flex items-center justify-between ...">
...
</div>
</div>
))}
### Step 3: Implement Lifecycle Interaction Trigger Engine
Inside ItineraryTimeline.tsx, listen to the passed history route parameters. Set the state, then handle the async smooth scrolling action:
import { useLocation } from 'react-router-dom';
const location = useLocation();
const [expandedStageId, setExpandedStageId] = useState<string | null>(null);
useEffect(() => {
if (location.state?.defaultExpandedLegId) {
const targetId = location.state.defaultExpandedLegId;
// 1. Instantly trigger the accordion state layout expansion
setExpandedStageId(targetId);
// 2. Schedule a deferred macro-task callback to allow DOM re-renders to finish
if (location.state?.shouldScrollToTarget) {
const scrollTimer = setTimeout(() => {
const targetElement = document.getElementById(`leg-anchor-node-${targetId}`);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
// Clear history router state token flags to prevent repeating scroll on subsequent reload shifts
window.history.replaceState({}, document.title);
}, 150); // 150ms ensures smooth accordion height deployment transition finishes
return () => clearTimeout(scrollTimer);
}
} else if (currentTour?.legs && currentTour.legs.length > 0 && !expandedStageId) {
setExpandedStageId(currentTour.legs[0].id);
}
}, [location.state, currentTour]);
## 4. Quality Control & Acceptance Verification
[ ] Date Matching Accuracy: Set today's date context matching a target Leg configuration profile. Tap the transition interface button from dashboard. The page must route directly and expand the target container block node.
[ ] Flush Viewport Ceiling Test: The animated target header segment node must slide upwards smoothly. It must lock positions cleanly right below the lowest baseline layer shadow boundary of the Tab Controller without passing behind it.
[ ] State Cleanliness Check: Refreshing or navigating back and forth within the active Timeline panel after the initial landing should not lock or force layout views to keep jumping scroll heights automatically.
+156
View File
@@ -0,0 +1,156 @@
# Enhancement Summary: Tag Selection & Photo Filtering
## Overview
Successfully enhanced the travel planning application with improved photo tagging UI and photo filtering capabilities on the explore map.
## Changes Implemented
### 1. Enhanced TagSelectModal Component ✅
**File**: `frontend/src/components/TagSelectModal.tsx`
**Features Added**:
- **Image Preview**: Shows the uploaded photo at the top of the modal (max-height: 192px, with rounded corners)
- **Custom Tag Input**: Text input with "Nhập thẻ mới..." placeholder
- **Add Custom Tags**: Button and Enter key support to add user-defined tags
- **Remove Custom Tags**: Trash icon on hover to delete custom tags
- **Visual Feedback**: Selected tags highlighted in different colors (blue for predefined, emerald for custom)
- **Summary Section**: Displays count and list of all selected tags before confirmation
**UI Improvements**:
- Sticky header and footer for easy access
- Separate sections for predefined tags and custom input
- Max-height with scrolling for long tag lists
- Smooth animations and transitions
### 2. LandingPage Integration ✅
**File**: `frontend/src/pages/LandingPage.tsx`
**Updates**:
- Added `photoPreviewUrl` state for temporary preview image
- Created object URL using `URL.createObjectURL()` when file is selected
- Pass preview URL to TagSelectModal component
- Proper cleanup with `URL.revokeObjectURL()` on modal close or after upload
- Included custom tags in upload formData as JSON
**State Management**:
```typescript
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
```
### 3. ExploreMap Photo Tag Filtering ✅
**File**: `frontend/src/pages/ExploreMap.tsx`
**Features Added**:
- **Photo Tag Filter State**: `selectedPhotoFilterTags` for tracking active filters
- **Available Photo Tags**: `availablePhotoTags` computed from all public photos' metadata
- **Enhanced Filter Dropdown**: Two-section filter UI:
- **Tour Section (🧳 Chuyến đi)**: Existing tour tags (single selection)
- **Photo Section (📸 Ảnh công khai)**: Photo tags from uploads (multiple selection)
- **Filtering Logic**: `groupedPhotos` useMemo filters photos based on selected tags
- **Dynamic Tag Population**: Photo tags automatically extracted from `photo.metadata.tags`
**Filtering Behavior**:
- Multiple photo tags can be selected simultaneously
- Photos matching ANY selected tag are displayed (OR logic)
- "Tất cả" button clears photo filters
- Filter is independent from tour tag filtering
## Technical Details
### Tag Storage
- Backend stores tags in `photo.metadata.tags` as JSON array
- No database schema changes required
- Flexible for custom tags without pre-definition
### Frontend State Management
```typescript
// State
const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState<string[]>([]);
// Computed
const availablePhotoTags = React.useMemo(() => {
const tagsSet = new Set<string>();
publicPhotos.forEach(photo => {
const tags = photo.metadata?.tags as string[] | undefined;
if (Array.isArray(tags)) {
tags.forEach(tag => tagsSet.add(tag));
}
});
return Array.from(tagsSet).sort();
}, [publicPhotos]);
// Filtered Results
const groupedPhotos = React.useMemo(() => {
let filteredPhotos = publicPhotos;
if (selectedPhotoFilterTags.length > 0) {
filteredPhotos = publicPhotos.filter((photo) => {
const photoTags = photo.metadata?.tags as string[] | undefined;
if (!Array.isArray(photoTags)) return false;
return selectedPhotoFilterTags.some(tag => photoTags.includes(tag));
});
}
// ... grouping and sorting logic
}, [publicPhotos, selectedPhotoFilterTags]);
```
## Testing Results
### Verified Functionality
✅ Image preview displays in TagSelectModal
✅ Custom tag input accepts user text
✅ Custom tags can be added with button or Enter key
✅ Custom tags can be removed with trash icon
✅ All selected tags display in summary
✅ Photo tag filtering works on ExploreMap
✅ Multiple photo tags can be selected
✅ "Tất cả" button clears filters
✅ Docker build successful (0 errors)
✅ All services running healthy
### Build Status
- Frontend build: ✅ Success
- Backend build: ✅ Success
- Container deployment: ✅ All 4 services running
- Browser testing: ✅ Filter dropdown functional
## User Workflow
### Photo Upload with Tags
1. User clicks "Chụp ảnh" button
2. Selects image from device
3. Image is displayed in TagSelectModal preview
4. User selects predefined tags from 11 categories
5. User can add custom tags in textbox
6. Confirms and photo is uploaded with all tags
7. Tags stored in database for filtering
### Photo Discovery with Filtering
1. User navigates to "Khám phá" (Explore)
2. Clicks filter button to open dropdown
3. Sees available photo tags from community uploads
4. Selects one or more tags to filter
5. Map refreshes showing only photos with selected tags
6. Clear selection with "Tất cả" button to see all photos again
## Files Modified
-`frontend/src/components/TagSelectModal.tsx` - Enhanced with preview and custom tags
-`frontend/src/pages/LandingPage.tsx` - Integration with preview URL
-`frontend/src/pages/ExploreMap.tsx` - Photo tag filtering implementation
## Browser Compatibility
- Modern browsers with ES6+ support
- Tested on latest Chrome/Firefox/Safari
- Mobile responsive design with touch support
## Performance Considerations
- Photo tag extraction done in useMemo (cached)
- Filter operations are optimized with Set for uniqueness
- Lazy filtering applied only to grouped photos
- No additional API calls needed (uses existing photo data)
## Future Enhancements
- Tag search/autocomplete in filter
- Tag popularity sorting
- Tag suggestions based on similar photos
- User tag preferences/favorites
- Tag analytics dashboard
-77
View File
@@ -1,77 +0,0 @@
import React, { useState, useMemo } from 'react';
import { Wallet, Users, Info } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
export const ExpenseManager = () => {
const { legs } = useTourStore();
const [adults, setAdults] = useState(2);
const [children, setChildren] = useState(1);
const [discount, setDiscount] = useState(30);
const totals = useMemo(() => {
const totalAmount = legs.reduce((acc, leg) =>
acc + leg.expenses.reduce((lAcc: number, exp: any) => lAcc + Number(exp.amount), 0), 0
);
const childRateFactor = 1 - (discount / 100);
const weightedCount = adults + (children * childRateFactor);
const adultPrice = totalAmount / weightedCount;
const childPrice = adultPrice * childRateFactor;
return {
total: totalAmount,
adultPrice: Math.round(adultPrice),
childPrice: Math.round(childPrice)
};
}, [legs, adults, children, discount]);
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
<div className="flex items-center gap-2 mb-6 text-blue-600">
<Users className="w-5 h-5" />
<h3 className="font-bold">Cấu hình thành viên</h3>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Người lớn</label>
<input type="number" value={adults} onChange={e => setAdults(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Trẻ em</label>
<input type="number" value={children} onChange={e => setChildren(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Giảm trẻ em (%)</label>
<input type="number" value={discount} onChange={e => setDiscount(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
<div className="bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200">
<div className="flex justify-between items-start mb-8">
<div>
<p className="text-blue-100 text-sm">Tổng chi phí chuyến đi</p>
<h2 className="text-3xl font-bold mt-1">{totals.total.toLocaleString()} VND</h2>
</div>
<Wallet className="w-8 h-8 opacity-20" />
</div>
<div className="grid grid-cols-2 gap-4 border-t border-blue-500 pt-6">
<div>
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi người lớn</p>
<p className="text-xl font-bold">{totals.adultPrice.toLocaleString()}đ</p>
</div>
<div>
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi trẻ em (-{discount}%)</p>
<p className="text-xl font-bold">{totals.childPrice.toLocaleString()}đ</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 text-gray-400 text-xs px-2">
<Info className="w-4 h-4" />
<p>Chi phí đưc tự đng tính toán dựa trên hóa đơn của các chặng.</p>
</div>
</div>
);
};
-204
View File
@@ -1,204 +0,0 @@
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTourStore } from './useTourStore.js';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2 } from 'lucide-react';
import { UserManagementModal } from './UserManagementModal.js';
import { CreateTourModal } from './CreateTourModal.js';
// Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
return null;
}
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
function MapTracker() {
const setMapCenter = useTourStore(state => state.setMapCenter);
useMapEvents({
moveend: (e) => {
const map = e.target;
const center = map.getCenter();
const zoom = map.getZoom();
const coords: [number, number] = [center.lat, center.lng];
setMapCenter(coords);
// Lưu vị trí và mức zoom vào localStorage để sử dụng cho lần sau
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
},
});
return null;
}
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => {
// Thêm fetchTour vào destructuring từ store
const { publicTours, fetchPublicTours, fetchTour, setMapCenter } = useTourStore();
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
const [initialViewState] = useState(() => {
const saved = localStorage.getItem('map_view_state');
if (saved) {
try { return JSON.parse(saved); } catch (e) { return null; }
}
return null;
});
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
const [mapZoom] = useState(initialViewState?.zoom || 13);
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
useEffect(() => {
fetchPublicTours();
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
if (!initialViewState) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserPos(posArray);
setMapCenter(posArray);
},
() => console.log("Không thể lấy vị trí người dùng")
);
} else {
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
setMapCenter(initialViewState.center);
}
}, []);
return (
<div className="h-screen w-full relative">
{/* Nút quay lại */}
<button
onClick={onBack}
className="absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all"
>
<X className="w-6 h-6 text-gray-800" />
</button>
{/* Nút đăng xuất - Chỉ hiển thị khi có user login */}
{onLogout && (
<button
onClick={onLogout}
className="absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700"
>
<LogOut className="w-5 h-5" />
<span className="hidden sm:inline">Đăng xuất</span>
</button>
)}
{/* Nút quản lý người dùng cho Admin */}
{user?.isAdmin && (
<button
onClick={() => setIsAdminModalOpen(true)}
className="absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
>
<Settings className="w-5 h-5" />
<span className="hidden sm:inline">Quản hệ thống</span>
</button>
)}
{/* Nút tạo Tour mới */}
{user && (
<button
onClick={() => setIsCreateModalOpen(true)}
className="absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold"
>
<Navigation className="w-5 h-5" />
<span className="hidden sm:inline">Tạo Tour mới</span>
</button>
)}
{/* Header Overlay */}
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
<div className="flex items-center gap-2">
<Navigation className="w-4 h-4 text-blue-600" />
<span className="font-bold text-gray-800">Đang khám phá khu vực của bạn</span>
</div>
</div>
<MapContainer
center={userPos}
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/>
{/* Theo dõi di chuyển bản đồ */}
<MapTracker />
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
<RecenterMap position={userPos} />
<MarkerClusterGroup chunkedLoading>
{publicTours.map((tour) => {
const startLoc = tour.legs?.[0]?.locations?.[0];
if (!startLoc) return null;
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
return (
<React.Fragment key={tour.id}>
{/* Tour Marker - Bong bóng chứa thumbnail. Click chuyển vào Dashboard */}
<Marker
position={[startLoc.latitude, startLoc.longitude]}
eventHandlers={{
click: () => onViewTour(tour.id)
}}
icon={L.divIcon({
className: 'custom-bubble',
html: `
<div class="relative group">
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${tourImage}" class="w-full h-full object-cover" />
</div>
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
S
</div>
</div>
`,
iconSize: [48, 48],
iconAnchor: [24, 24]
})}
/>
</React.Fragment>
);
})}
</MarkerClusterGroup>
</MapContainer>
{/* Admin Modal */}
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
{/* Create Tour Modal */}
<CreateTourModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={(tour) => {
fetchTour(tour.id);
onViewTour(tour.id);
}}
/>
</div>
);
};
+104
View File
@@ -0,0 +1,104 @@
# To AI Agent: Fix Infinite Re-render Loop and Flickering "ĐANG TÌM ĐƯỜNG TỐI ƯU..." Label
## 1. Context & Layout Bug Analysis
We are resolving a critical performance and UI bug inside the Map routing engine interface (`image.png`):
- **The Issue:** The floating loading indicator badge reading `"ĐANG TÌM ĐƯỜNG TỐI ƯU..."` keeps flashing, flickering, or appearing and disappearing in an infinite execution loop.
- **Root Cause:** This is caused by a broken React lifecycle loop. Every time the map triggers a directions route query, it toggles a loading state variable (`isSearching: true`). Once the route loads, the state updates (`isSearching: false`), forcing a component re-render. If the coordinates object (`origin`, `destination`) or the map instance ref inside the `useEffect` dependency array changes its reference pointer on every render, the hook fires *again*, creating an endless loop of API fetching and component flashing.
---
## 2. Technical Execution Strategy
To terminate this flickering cycle, we must enforce a strict guard rail on the network/calculation trigger pipeline:
1. **Coordinate Reference Stability:** Deconstruct the input latitude/longitude objects into raw primitive string values (e.g., `origin.lat`, `origin.lng`) inside the hook dependency array to avoid object reference mutations triggering re-renders.
2. **Locking Ref Mechanism:** Implement a mutable React tracking reference (`const queryInProgress = useRef(false)`) to lock the execution window. If a fetch operation is active, block subsequent duplicate queries from firing.
3. **Clean Loading State Termination:** Turn off the search state flag explicitly only *after* the route geometry polyline has completely finished rendering onto the map viewport layout.
---
## 3. Code Refactoring Blueprint
Locate your map navigation layer or modal component (e.g., `LocationNavigationModal.tsx`) and refactor the execution lifecycle loop as defined below:
```typescript
import React, { useEffect, useState, useRef } from 'react';
// Inside your Map Navigation Modal / Layer component wrapper:
export const LocationNavigationModal = ({ isOpen, routeData }) => {
const [isSearchingRoute, setIsSearchingRoute] = useState(false);
const mapInstanceRef = useRef<any>(null);
// CRITICAL FIX 2: Guard mechanism to block concurrent duplicated queries
const fetchLockRef = useRef(false);
// Deconstruct coordinate primitives to secure a stable dependency array
const originLat = routeData?.origin?.lat;
const originLng = routeData?.origin?.lng;
const destLat = routeData?.destination?.lat;
const destLng = routeData?.destination?.lng;
useEffect(() => {
if (!isOpen || !originLat || !originLng || !destLat || !destLng) return;
// If an operation is already locked and active, bail out immediately to prevent loops
if (fetchLockRef.current) return;
const calculateOptimalRoute = async () => {
try {
// 1. Activate loading feedback banner
setIsSearchingRoute(true);
fetchLockRef.current = true; // Engage execution lock
console.log("Fetching route coordinates exactly once...");
// --- YOUR MAP COMPONENT ROUTING LOGIC START ---
// Example: const response = await directionsService.route({...});
// await mapInstanceRef.current.drawPolyline(response);
// --- YOUR MAP COMPONENT ROUTING LOGIC END ---
} catch (error) {
console.error("Failed to compile route optimization maps:", error);
} finally {
// 2. Safe, definitive termination of the tracking states
setIsSearchingRoute(false);
fetchLockRef.current = false; // Disengage execution lock
}
};
calculateOptimalRoute();
// Cleanup phase: Reset execution parameters when inputs dismantle or modal closes
return () => {
fetchLockRef.current = false;
setIsSearchingRoute(false);
};
/* CRITICAL FIX 1: Explicitly tracking primitives only.
Do NOT pass full 'routeData', 'mapInstanceRef' or object literals here!
*/
}, [isOpen, originLat, originLng, destLat, destLng]);
return (
<div className="relative w-full h-full">
{/* Map Content Target Canvas */}
<div id="navigation-viewport-map-canvas" className="w-full h-full" />
{/* RENDER CONTROLLER: Only mount the label if routing calculations are actively processing */}
{isSearchingRoute && (
<div className="absolute top-16 left-4 z-30 bg-white/95 dark:bg-slate-900/95 border border-slate-200 dark:border-slate-800 px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 animate-pulse">
{/* Circular Loading Spinner Element */}
<div className="w-3.5 h-3.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-[11px] font-bold text-slate-700 dark:text-slate-200 uppercase tracking-wider">
ĐANG TÌM ĐƯỜNG TỐI ƯU...
</span>
</div>
)}
</div>
);
};
## 4. Verification & Quality Acceptance Criteria
[ ] Single Instance Trigger: Check console telemetry outputs. When the map modal mounts, the route compilation routine must print its trace log exactly once.
[ ] Flicker Nullification: The "ĐANG TÌM ĐƯỜNG TỐI ƯU..." badge must display smoothly with an animation pulse. It must not shake, blink, flash, or rapid-cycle on and off.
[ ] Deterministic Hiding: As soon as the blue route line draws completely across the map terrain grid layout, the loading badge must cleanly unmount and disappear from view without reappearing unless a new destination node button is clicked.
-343
View File
@@ -1,343 +0,0 @@
import React from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
if (!actual) return null;
const diff = differenceInMinutes(parseISO(actual), parseISO(planned));
const isLate = diff > 0;
return (
<div className={`flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`}>
{isLate ? <AlertCircle className="w-3 h-3 mr-1" /> : <CheckCircle2 className="w-3 h-3 mr-1" />}
<span>
{isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút`}
</span>
</div>
);
};
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
const p = 0.017453292519943295; // Math.PI / 180
const c = Math.cos;
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
c(lat1 * p) * c(lat2 * p) *
(1 - c((lon2 - lon1) * p)) / 2;
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
};
const formatTravelTime = (minutes: number) => {
if (minutes < 60) return `${minutes} phút`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
};
export const ItineraryTimeline = ({
onAddLocation,
onEditLocation
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
const toggleComplete = async (locationId: string) => {
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
console.log("Toggle status for location:", locationId);
};
const handleAddLeg = async () => {
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
if (note && currentTour) {
await addLeg(currentTour.id, { note });
}
};
const handleDeclareLegs = async () => {
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
const count = parseInt(countStr || "0");
if (count > 0 && currentTour) {
await initializeLegs(currentTour.id, count);
}
};
const handleEditLeg = async (leg: any) => {
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
if (note !== null) {
await updateLeg(leg.id, { note });
}
};
const handleDeleteLeg = async (legId: string) => {
if (window.confirm("Bạn có chắc chắn muốn xóa chặng này?")) {
try {
await deleteLeg(legId);
} catch (err: any) {
alert(err.message);
}
}
};
const handleDeleteLocation = async (id: string) => {
if (window.confirm("Bạn có chắc chắn muốn xóa địa điểm này?")) {
try {
await deleteLocation(id);
} catch (err: any) { alert(err.message); }
}
};
return (
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
<div className="px-2 pt-4">
{legs.length === 0 ? (
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 font-medium">Chưa chặng nào trong lộ trình.</p>
</div>
) : (
legs.map((leg, legIdx) => {
const totalDwellMinutes = leg.locations.reduce((acc: number, loc: any) => {
if (loc.plannedStart && loc.plannedEnd) {
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
}
return acc;
}, 0);
// Xác định địa điểm cuối cùng của chặng trước đó để hiển thị tính liên tục
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
return (
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Leg Header */}
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm">
{leg.sequence}
</span>
{leg.note || `Chi tiết Chặng ${leg.sequence}`}
</div>
{prevLegLastLoc && (
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
<Navigation className="w-2.5 h-2.5 rotate-90" /> Tiếp nối từ {prevLegLastLoc.name}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4">
{['OWNER', 'MANAGER'].includes(userRole || '') && (
<>
<button
onClick={() => onAddLocation?.(leg.id)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
title="Thêm địa điểm vào chặng này"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={() => handleEditLeg(leg)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteLeg(leg.id)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
{leg.totalDistance !== undefined && (
<div className="hidden sm:flex items-center gap-2">
<div className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100">
{leg.totalDistance} km
</div>
<div className="text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1">
<Clock className="w-3 h-3" />
~ {formatTravelTime(Math.round((leg.totalDistance / 35) * 60))}
</div>
</div>
)}
{totalDwellMinutes > 0 && (
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
<Clock className="w-3 h-3" />
Dừng: {formatTravelTime(totalDwellMinutes)}
</div>
)}
</div>
{['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (
<button
onClick={() => optimizeRouting(leg.id)}
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all"
>
<Zap className="w-3 h-3" />
Tối ưu
</button>
)}
</div>
{/* Vertical Line for the whole leg */}
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
<div className="ml-2">
{leg.locations.map((location, idx) => {
// Logic quan trọng: Gán điểm cuối chặng này nối với điểm đầu chặng sau
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
const distanceToNext = nextLocation
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
: null;
const averageSpeed = 35; // km/h
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
const dwellMinutes = (location.plannedStart && location.plannedEnd)
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
: null;
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
return (
<div key={location.id}>
<div className="relative flex group mb-6">
{/* Timeline Node */}
<div className="z-10 mt-1.5 mr-4">
<button
onClick={() => toggleComplete(location.id)}
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
>
{location.status === 'COMPLETED' ? (
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
) : (
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
)}
</button>
</div>
{/* Card Content */}
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
}`}>
<div className="flex justify-between items-start">
<div>
{isStartPoint && (
<span className="inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm bắt đu</span>
)}
{isEndPoint && (
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
)}
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
{location.name}
</h3>
<div className="flex items-center text-sm text-gray-500 mt-1">
<MapPin className="w-3 h-3 mr-1" />
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
</div>
{location.note && (
<div className="mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic">
{location.note}
</div>
)}
{dwellMinutes !== null && (
<div className="flex items-center text-xs text-amber-600 font-medium mt-1">
<Clock className="w-3 h-3 mr-1" />
<span>Thời gian dừng: {formatTravelTime(dwellMinutes)}</span>
</div>
)}
{locationExpense && (
<div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1">
<div className="flex items-center gap-1 font-bold">
<Zap className="w-3 h-3" />
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({locationExpense.category})</span>
</div>
{locationExpense.description && (
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
)}
{locationExpense.note && (
<div className="text-[10px] text-gray-500 italic">{locationExpense.note}</div>
)}
{locationExpense.paidBy && (
<div className="text-[10px] font-semibold text-indigo-700">Đã thanh toán: {locationExpense.paidBy.name}</div>
)}
</div>
)}
</div>
<div className="text-right flex flex-col items-end">
<div className="flex items-center text-sm font-medium text-blue-600">
<Clock className="w-3 h-3 mr-1" />
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
</div>
{location.status === 'COMPLETED' && location.actualStart && (
<div className="text-[10px] text-gray-400 mt-1 italic">
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
</div>
)}
{['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (
<div className="flex gap-1 mt-2">
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
<Edit2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDeleteLocation(location.id)} className="p-1 text-gray-400 hover:text-red-600 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
{/* Logic tính toán độ lệch thời gian */}
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
</div>
</div>
{distanceToNext !== null && travelTimeMinutes !== null && (
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3">
<div className="w-8 flex justify-center">
<Navigation className="w-3 h-3 text-blue-400 rotate-180" />
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100">
{distanceToNext.toFixed(2)} km
</span>
<span className="text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
~ {formatTravelTime(travelTimeMinutes)}
</span>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
})
)}
{/* Actions at the bottom of the list */}
{['OWNER', 'MANAGER'].includes(userRole || '') && (
<div className="flex flex-col gap-3 pb-20 mt-8">
<button
onClick={handleDeclareLegs}
className="w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold"
>
<List className="w-5 h-5" />
{legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"}
</button>
<button
onClick={handleAddLeg}
className="w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold"
>
<Plus className="w-5 h-5" /> Thêm chặng lẻ vào cuối
</button>
</div>
)}
</div>
</div>
);
};
+132
View File
@@ -0,0 +1,132 @@
# To AI Agent: Implement Clickable Google Maps Hyperlinks in PDF Export Table
## 1. Context & Feature Objective
We are upgrading the PDF export functionality within the Yotrip Travel Planner application. Currently, the PDF generates a static table containing location names, addresses, and coordinates.
**Objective:** Automatically turn every location row inside the PDF table into an interactive, clickable hyperlink. When a user clicks on the location cell in the generated PDF document, it must immediately open a browser tab navigating directly to that exact location on Google Maps.
---
## 2. Technical Architecture & Data Strategy
Because `jspdf-autotable` draws text onto a canvas layout, raw HTML tags like `<a>` will fail. We must implement a two-step rendering lifecycle:
1. **Extraction & Styling State:** During the data loop, verify coordinates or address data. Generate a standard universal Google Maps search URL, save it into a coordinate-tracking index object, and transform the raw text cell into a styled cell object (Yale Blue text `#28536b` to mimic an online link).
2. **Link Injection Layer:** Utilize the `didDrawCell` hook callback inside the `doc.autoTable` configuration to position a native invisible link window (`doc.link()`) precisely over the drawn dimensions of that specific location cell.
---
## 3. Code Refactoring Specification
### Step 1: Update Data Preparation Loop
Locate the loop processing `currentTour.legs` inside your PDF generation code block. Initialize a temporary lookup map array named `pdfMapLinks` and refactor the item distribution logic as follows:
```typescript
// Initialize an index mapping registry for hyperlinks before the loop execution
const pdfMapLinks: { [key: number]: string } = {};
if (currentTour?.legs && currentTour.legs.length > 0) {
currentTour.legs.forEach((leg: any, legIdx: number) => {
const legName = leg.note || `Chặng ${legIdx + 1}`;
const legLocations = (leg.locations || []).filter((loc: any) =>
loc && (loc.plannedStart || loc.plannedEnd || loc.name)
);
const startRow = globalRowIndex;
if (legLocations.length > 0) {
legRowRanges[leg.id] = { start: startRow, count: legLocations.length };
legLocations.forEach((loc: any, locIdx: number) => {
const isStartPoint = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
const timeSource = isStartPoint ? loc.plannedEnd : loc.plannedStart;
const timeStr = timeSource ? formatDateTime(timeSource) : '';
const locName = loc.name || '';
const addressStr = loc.address || '';
const coordStr = loc.latitude && loc.longitude
? `\n${loc.latitude}, ${loc.longitude}`
: '';
const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n');
const noteText = loc.note || '';
// 1. Generate standard universal Google Maps URL query pattern
let mapUrl = '';
if (loc.latitude && loc.longitude) {
// Absolute Precision using GPS coordinates
mapUrl = `https://www.google.com/maps/search/?api=1&query=${loc.latitude},${loc.longitude}`;
} else if (addressStr || locName) {
// Text-search query fallback if GPS coordinates are missing
mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(addressStr || locName)}`;
}
// 2. Register current row array position to map lookup index
const currentRowPosition = tableRows.length;
if (mapUrl) {
pdfMapLinks[currentRowPosition] = mapUrl;
}
// 3. Convert raw string into custom styled autoTable Cell configuration object
const locationCellObj = {
content: locationText,
// Apply custom link colors matching theme style #28536b (Yale Blue)
styles: mapUrl ? { textColor: [40, 83, 107], fontStyle: 'bold' as const } : {}
};
tableRows.push([
stt++,
timeStr,
locIdx === 0 ? legName : '',
locationCellObj, // Injected as cell structure object
noteText
]);
globalRowIndex++;
});
} else {
legRowRanges[leg.id] = { start: startRow, count: 1 };
tableRows.push([
stt++,
'',
legName,
'Chưa có địa điểm trong chặng này',
''
]);
globalRowIndex++;
}
});
}
### Step 2: Inject Coordinate-Based Overlay inside doc.autoTable
Locate the core configuration module block where doc.autoTable({}) is invoked. Inject the didDrawCell event engine to deploy the link bounds:
doc.autoTable({
head: [['STT', 'Thời gian', 'Chặng', 'Địa điểm', 'Ghi chú']],
body: tableRows,
theme: 'grid',
// HOOK HANDLER: Overlays active coordinate zones on top of native cells after writing
didDrawCell: (data: any) => {
// Target explicitly: body section only + column index 3 (Location Column)
if (data.section === 'body' && data.column.index === 3) {
const activeRowIndex = data.row.index;
const targetMapUrl = pdfMapLinks[activeRowIndex];
if (targetMapUrl) {
// Build active link container utilizing jsPDF coordinates framework
data.doc.link(
data.cell.x,
data.cell.y,
data.cell.width,
data.cell.height,
{ url: targetMapUrl }
);
}
}
},
styles: { font: 'Roboto' } // Retain existing styling structure
});
## 4. Quality Control & Acceptance Criteria
[ ] Visual Differentiation: Location text cells containing map URLs must render cleanly in bold dark-blue ([40, 83, 107]) while empty state texts stay muted.
[ ] Coordinate Precision: Clicking a location card possessing explicit coordinates (latitude, longitude) must directly map to those absolute markers instead of doing an inaccurate keyword address query.
[ ] Boundary Accuracy: The click targets must fit perfectly inside the grid cell borders. Clicking near the edges of column 3 must register properly, while clicking column 2 (Leg name) or column 4 (Notes) must remain non-reactive.
-162
View File
@@ -1,162 +0,0 @@
import React, { useState, useMemo } from 'react';
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react';
import { LoginModal } from './LoginModal.js';
const TRAVEL_IMAGES = [
"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1503220317375-aaad61436b1b?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1513581166391-887a96df91e7?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1530789253388-582c481c54b0?auto=format&fit=crop&q=80"
];
interface LandingPageProps {
onContinue?: () => void;
onGoToSignup?: () => void;
onGoToMap?: () => void;
onLoginSuccess?: (user: any) => void;
isInitialSetup?: boolean;
}
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
// Chọn ngẫu nhiên một hình ảnh khi người dùng truy cập trang
const backgroundImage = useMemo(() => {
return TRAVEL_IMAGES[Math.floor(Math.random() * TRAVEL_IMAGES.length)];
}, []);
return (
<div className="flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans bg-gray-50 relative">
{/* Logo/Brand Header ở góc trên bên trái */}
<div className="absolute top-8 left-8 z-20 flex items-center gap-3 text-white md:text-white drop-shadow-lg">
<Compass className="w-10 h-10" />
<span className="text-2xl font-black tracking-tighter uppercase">Travel Planner</span>
</div>
{/* Nửa bên trái: Hiển thị một hình ảnh duy nhất */}
<div className="hidden md:block md:w-1/2 relative h-full bg-blue-900">
<img
src={backgroundImage}
className="w-full h-full object-cover opacity-70"
alt="Travel Background"
/>
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-transparent pointer-events-none" />
{/* Thumbnail Grid ở góc dưới bên trái */}
<div className="absolute bottom-12 left-8 z-20 flex flex-col gap-2 items-start drop-shadow-2xl">
{/* Hàng trên cùng: 1 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[0]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 1" />
</div>
{/* Hàng thứ 2: 2 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[1]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 2" />
<img src={TRAVEL_IMAGES[2]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 3" />
</div>
{/* Hàng dưới cùng: 3 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[3]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 4" />
<img src={TRAVEL_IMAGES[4]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 5" />
<img src={TRAVEL_IMAGES[5]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 6" />
</div>
</div>
</div>
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
<div className="flex-1 md:w-1/2 flex flex-col justify-center items-center bg-white p-8 lg:p-16 h-full overflow-y-auto">
<div className="max-w-md w-full space-y-12">
<div className="space-y-4">
{isInitialSetup ? (
<>
<div className="inline-flex items-center px-3 py-1 rounded-full bg-amber-50 text-amber-700 text-xs font-bold border border-amber-100 mb-2">
<ShieldCheck className="w-3 h-3 mr-1" /> CẤU HÌNH HỆ THỐNG LẦN ĐU
</div>
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
Thiết lập Quản trị viên
</h1>
<p className="text-lg text-gray-500 leading-relaxed">
Chào mừng! Hệ thống vừa đưc cài đt. Vui lòng tạo tài khoản đu tiên đ quản vận hành trang web.
</p>
</>
) : (
<>
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
Lên kế hoạch cho hành trình tiếp theo
</h1>
<p className="text-lg text-gray-500 leading-relaxed">
Khám phá các đa điểm nổi bật qua bản đ cộng đng hoặc đăng nhập đ bắt đu tự tạo chuyến đi cho riêng mình.
</p>
</>
)}
</div>
<div className="grid grid-cols-1 gap-4">
{isInitialSetup && (
<button
onClick={onGoToSignup}
className="flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl transition-all shadow-xl shadow-blue-100 animate-bounce-subtle mb-2"
>
<UserPlus className="w-5 h-5" />
Bắt đu thiết lập ngay
</button>
)}
<button
onClick={onGoToMap}
className="flex items-center justify-center gap-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-lg shadow-indigo-100"
>
<MapIcon className="w-5 h-5" />
Khám phá các hành trình du lịch
</button>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-2 bg-blue-50 hover:bg-blue-100 text-blue-700 font-bold py-4 rounded-2xl transition-all"
>
<LogIn className="w-5 h-5" />
Đăng nhập
</button>
<button
onClick={onGoToSignup}
className="flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-100 transition-all"
>
<UserPlus className="w-5 h-5" />
Đăng
</button>
</div>
<button
onClick={onContinue}
className="flex items-center justify-center gap-3 text-gray-400 hover:text-gray-600 py-2 transition-all text-sm font-medium"
>
Tiếp tục tham quan nh
<ArrowRight className="w-5 h-5" />
</button>
</div>
<div className="pt-12 flex items-center gap-4 text-sm text-gray-400 border-t border-gray-100">
<div className="flex -space-x-3">
{[1, 2, 3, 4].map(i => (
<img key={i} className="w-10 h-10 rounded-full border-4 border-white" src={`https://i.pravatar.cc/100?img=${i+20}`} alt="user" />
))}
</div>
<p className="font-medium">Tham gia cùng +2,400 người du lịch khác</p>
</div>
</div>
</div>
{/* Login Modal Component */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
onSwitchToSignup={onGoToSignup}
onLoginSuccess={onLoginSuccess}
/>
</div>
);
};
-141
View File
@@ -1,141 +0,0 @@
import React, { useState } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
onSwitchToSignup?: () => void;
onLoginSuccess?: (user: any) => void;
}
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitchToSignup, onLoginSuccess }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập thất bại');
}
// Lưu phiên đăng nhập
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
{/* Backdrop */}
<div
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Modal Content */}
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
<div className="p-8 sm:p-10">
<div className="flex justify-between items-start mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900">Đăng nhập</h2>
<p className="text-gray-500 mt-2">Chào mừng bạn quay trở lại!</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
{error}
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-semibold text-gray-700 ml-1">Email</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center px-1">
<label className="text-sm font-semibold text-gray-700">Mật khẩu</label>
<button className="text-xs font-bold text-blue-600 hover:text-blue-700">Quên mật khẩu?</button>
</div>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98] disabled:opacity-50"
>
{isLoading ? 'Đang xử lý...' : 'Đăng nhập'}
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : <ArrowRight className="w-5 h-5" />}
</button>
</form>
<div className="mt-10 pt-8 border-t border-gray-100 text-center">
<p className="text-gray-500">
Chưa tài khoản?{' '}
<button
onClick={() => { onClose(); onSwitchToSignup?.(); }}
className="font-bold text-blue-600 hover:underline"
>
Tạo tài khoản ngay
</button>
</p>
</div>
</div>
</div>
</div>
);
};
+133
View File
@@ -0,0 +1,133 @@
# Travel Planning - Multi-User Travel Itinerary Management System
A comprehensive travel planning application that enables collaborative itinerary planning, expense tracking, and photo sharing for travel groups.
## Features
- **Tour Management**: Create and manage travel tours with multiple legs (stages) and locations
- **Multi-user Collaboration**: Invite friends to join tours with role-based access control
- **Interactive Maps**: Visual tour planning with Leaflet.js integration
- **Expense Tracking**: Automatic cost splitting with configurable adult/child discounts
- **Photo Sharing**: Secure photo album with privacy controls (PUBLIC, TOUR_ONLY, PRIVATE)
- **Real-time Navigation**: Live GPS tracking and route optimization using OSRM API
## Tech Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **Frontend** | React + Vite | Single Page Application with fast HMR |
| | TailwindCSS | Utility-first CSS framework |
| | Zustand | Lightweight state management |
| | Leaflet.js | Interactive map rendering |
| **Backend** | NestJS | Scalable Node.js framework |
| | JWT | Authentication & authorization |
| | Prisma ORM | Type-safe database access |
| | PostgreSQL + PostGIS | Spatial database for geographic data |
## Project Structure
```
travelplanning/
├── backend/ # Backend API server
│ ├── src/
│ │ ├── auth/ # Authentication modules
│ │ ├── main.ts # NestJS entry point
│ │ └── v1/ # API v1 endpoints
│ └── prisma/
│ └── schema.prisma # Database schema
├── frontend/ # React frontend
│ └── src/
│ ├── pages/ # Main pages
│ ├── components/ # Reusable UI components
│ ├── hooks/ # Custom React hooks
│ └── store/ # Zustand stores
├── docs/ # Documentation
│ ├── ARCHITECTURE.md # System architecture
│ └── UITourDesign.md # UI design specifications
└── .env # Environment variables
```
## Database Schema
The application uses PostgreSQL with Prisma ORM. Key models include:
- **User**: Registered users with admin capability
- **Tour**: Travel itineraries with date ranges and participant management
- **Leg**: Stages within a tour (ordered sequence)
- **Location**: Geographic points with timing and status tracking
- **Expense**: Cost tracking linked to legs/locations
- **Photo**: Media storage with privacy controls
- **TourParticipant**: Many-to-many relationship with role-based permissions
### User Roles
| Role | Permissions |
|------|-------------|
| OWNER | Full access to all features |
| MANAGER | Can edit tour content and manage members |
| MEMBER | View tour and participate, access financial data |
| MEMBER_NO_FINANCE | View tour only, no financial access |
| VIEWER_ONLY | Read-only access to itinerary and photos |
## API Endpoints
### Tours
- `GET /api/v1/tours` - Get all public tours
- `POST /api/v1/tours` - Create new tour
- `GET /api/v1/tours/:id` - Get tour details
- `PUT /api/v1/tours/:id` - Update tour
### Authentication
- `POST /api/v1/auth/login` - User login
- `POST /api/v1/auth/register` - User registration
- `POST /api/v1/auth/promote-admin` - Admin role promotion (with secret key)
### Photos
- `GET /api/v1/public-photos` - Get public photos
- `POST /api/v1/tours/:id/photos` - Upload tour photos
## Quick Start
### Prerequisites
- Node.js 18+
- PostgreSQL with PostGIS extension
### Installation
```bash
# Install dependencies
npm install
cd frontend && npm install
cd ../backend && npm install
# Set up database
npx prisma migrate dev
npx prisma generate
# Start development servers
npm run dev # Frontend (Vite)
npm run start:backend # Backend (NestJS)
```
### Environment Variables
Create `.env` in the root directory:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/traveldb"
JWT_SECRET="your-secret-key"
```
## Mobile Optimization
The application is built mobile-first with support for:
- Safe area insets for notch displays (iOS/Android)
- Touch gestures for map interactions
- Responsive layouts for all screen sizes
- Device orientation and compass integration
## Documentation
See the `docs/` directory for detailed documentation:
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - System architecture and data models
- [UITourDesign.md](docs/UITourDesign.md) - UI design specifications
-196
View File
@@ -1,196 +0,0 @@
import React, { useState } from 'react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin } from 'lucide-react';
interface SignupPageProps {
onBack: () => void;
onSuccess: () => void;
}
export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
phone: '',
address: ''
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Mật khẩu xác nhận không khớp');
return;
}
setIsLoading(true);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
password: formData.password,
name: formData.name,
phone: formData.phone || undefined,
address: formData.address || undefined
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng ký thất bại');
}
onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex h-screen w-full overflow-hidden font-sans bg-white">
{/* Nửa bên trái: Hình ảnh decor (Đồng bộ với LandingPage) */}
<div className="hidden lg:block lg:w-1/2 relative bg-blue-900">
<img
src="https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
alt="Travel background"
className="absolute inset-0 h-full w-full object-cover opacity-50"
/>
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/40 to-transparent" />
<div className="absolute top-12 left-12 flex items-center gap-2 text-white z-10">
<Compass className="w-8 h-8" />
<span className="text-2xl font-black uppercase tracking-tighter">Travel Planner</span>
</div>
<div className="absolute bottom-20 left-12 text-white z-10 max-w-md">
<h2 className="text-4xl font-bold mb-4">Bắt đu hành trình của riêng bạn.</h2>
<p className="text-blue-100 opacity-80">Tạo tài khoản đ lưu lại những kế hoạch du lịch tuyệt vời nhất cùng bạn .</p>
</div>
</div>
{/* Nửa bên phải: Form đăng ký */}
<div className="flex-1 flex flex-col justify-center px-8 sm:px-16 lg:px-24 py-12 overflow-y-auto">
<div className="max-w-md w-full mx-auto">
<button onClick={onBack} className="flex items-center text-gray-400 hover:text-blue-600 mb-8 transition-colors font-medium">
<ChevronLeft className="w-5 h-5" /> Quay lại
</button>
<div className="mb-10">
<h1 className="text-3xl font-extrabold text-gray-900">Tạo tài khoản mới</h1>
<p className="text-gray-500 mt-2 font-medium">Khám phá các tính năng lập kế hoạch chuyên nghiệp.</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
{error}
</div>
)}
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Họ tên</label>
<div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="text"
placeholder="Nguyễn Văn A"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Email</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="email"
placeholder="email@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
<div className="relative group">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="tel"
placeholder="0912 345 678"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Đa chỉ</label>
<div className="relative group">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="text"
placeholder="Quận 1, TP.HCM"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<button
disabled={isLoading}
type="submit"
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 rounded-2xl shadow-xl shadow-blue-100 transition-all active:scale-[0.98] disabled:opacity-50 mt-4"
>
{isLoading ? 'Đang xử lý...' : 'Tạo tài khoản'}
{!isLoading && <ArrowRight className="w-5 h-5" />}
</button>
</form>
</div>
</div>
</div>
);
};
-644
View File
@@ -1,644 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { ItineraryTimeline } from './ItineraryTimeline.js';
import { ExpenseManager } from './ExpenseManager.js';
import { useTourStore } from './useTourStore.js';
import { AddLocationModal } from './AddLocationModal.js';
import { AddMemberModal } from './AddMemberModal.js';
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
import { useMap } from 'react-leaflet';
import {
Map as MapIcon,
Wallet,
Image as ImageIcon,
Calendar,
Users,
ChevronLeft,
Settings,
Quote,
Plus,
List,
Map as MapIconLucide,
MapPin,
Flag
} from 'lucide-react';
import L from 'leaflet';
// Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma
type LocationType = 'MOVE' | 'VISIT' | 'REST' | 'EAT';
// Fix lỗi icon mặc định của Leaflet cho môi trường Vite
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
// Định nghĩa các static icons để ngăn chặn việc khởi tạo lại liên tục gây crash khi unmount
const START_ICON = L.divIcon({
className: 'custom-marker-s',
html: `<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">S</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const END_ICON = L.divIcon({
className: 'custom-marker-e',
html: `<div class="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">E</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const VISIT_ICON = L.divIcon({
className: 'custom-marker-v',
html: `<div class="w-4 h-4 bg-indigo-500 rounded-full border-2 border-white shadow-md"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình
const MapTourBounds = ({ locations }: { locations: any[] }) => {
const map = useMap();
// Tạo một key dựa trên giá trị tọa độ để tránh chạy lại khi chỉ thay đổi tham chiếu mảng
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]);
useEffect(() => {
if (locations.length > 0) {
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
if (locations.length === 1) {
// Chỉ thực hiện nếu bản đồ chưa ở đúng vị trí (tránh trigger moveend liên tục)
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
} else {
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
}
}
}, [locKey, map]);
return null;
};
// Menu ngữ cảnh cho bản đồ
const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => {
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
// Sử dụng selector để tránh re-render khi mapCenter thay đổi
const legs = useTourStore(state => state.legs);
const setMapCenter = useTourStore(state => state.setMapCenter);
useMapEvents({
contextmenu: (e) => {
// Ngăn menu mặc định của trình duyệt hiện lên.
if (e.originalEvent) {
L.DomEvent.preventDefault(e.originalEvent);
L.DomEvent.stopPropagation(e.originalEvent);
}
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 });
},
moveend: (e) => {
const map = e.target;
const center = map.getCenter();
const zoom = map.getZoom();
const coords: [number, number] = [center.lat, center.lng];
// Chỉ cập nhật store nếu tọa độ thay đổi đáng kể (> 0.0001) để tránh loop
const currentStored = useTourStore.getState().mapCenter;
const diff = Math.abs(currentStored[0] - coords[0]) + Math.abs(currentStored[1] - coords[1]);
if (diff > 0.0001) {
setMapCenter(coords);
}
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
},
click: () => setMenuPos(null),
dragstart: () => setMenuPos(null),
});
// Ngăn chặn các sự kiện của bản đồ khi tương tác với menu
useEffect(() => {
if (menuPos && menuRef.current) {
L.DomEvent.disableClickPropagation(menuRef.current);
L.DomEvent.disableScrollPropagation(menuRef.current);
}
}, [menuPos]);
if (!menuPos) return null;
return (
<div
ref={menuRef}
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
style={{ top: menuPos.y, left: menuPos.x }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<button onClick={() => { onAction('START', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-600" /> Bắt đu từ đây
</button>
<button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-green-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50">
<div className="w-2 h-2 rounded-full bg-green-600" /> Kết thúc đây
</button>
<div className="px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest">Thêm vào chặng</div>
{legs.map(leg => (
<button
key={leg.id}
onClick={() => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-medium text-gray-600 truncate"
>
Chặng {leg.sequence}: {leg.note || 'Không có ghi chú'}
</button>
))}
</div>
);
};
export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [targetLegId, setTargetLegId] = useState<string | null>(null);
const [editingLocation, setEditingLocation] = useState<any>(null);
// Khôi phục vị trí và mức zoom từ localStorage
const [initialViewState] = useState(() => {
const saved = localStorage.getItem('map_view_state');
if (saved) {
try { return JSON.parse(saved); } catch (e) { return null; }
}
return null;
});
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
// Gom các store actions/state lại để tối ưu hóa re-render
const {
currentTour, legs, fetchTour, fetchPublicTours, publicTours,
userRole, mapCenter, setMapCenter, updateTourStartPoint,
updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember
} = useTourStore();
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
const [mapZoom] = useState(initialViewState?.zoom || 13);
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
useEffect(() => {
if (initialViewState) {
setMapCenter(initialViewState.center);
}
const loadData = async () => {
// Nếu chưa có tour nào trong store, thử tải danh sách public trước
if (publicTours.length === 0) {
await fetchPublicTours();
}
};
loadData();
}, []);
useEffect(() => {
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo
if (publicTours.length > 0 && !currentTour) {
fetchTour(publicTours[0].id);
}
}, [publicTours, currentTour, fetchTour]);
// Hàm xử lý khai báo số chặng
const handleDeclareLegs = async () => {
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
const count = parseInt(countStr || "0");
if (count > 0 && currentTour) {
await initializeLegs(currentTour.id, count);
}
};
// Hàm xử lý các hành động từ Context Menu của bản đồ
const handleMapAction = async (action: string, latlng: L.LatLng) => {
// Bước 1: Lấy tọa độ (lat, lng) tại vị trí click (đã nhận qua tham số latlng)
console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng });
// Đảm bảo có tour và ít nhất một chặng để ghim
const currentLegs = useTourStore.getState().legs;
if (!currentTour || currentLegs.length === 0) {
alert("Tour chưa có chặng nào. Vui lòng tạo chặng (Leg) trước khi thực hiện.");
return;
}
let targetLegId = currentLegs[0].id; // Mặc định là chặng đầu
let defaultName = "Địa điểm mới";
let locationType: LocationType = 'VISIT'; // Mặc định là tham quan
if (action === 'START') {
defaultName = "Điểm bắt đầu";
locationType = 'MOVE'; // Điểm bắt đầu thường liên quan đến di chuyển
}
if (action === 'END') {
targetLegId = currentLegs[currentLegs.length - 1].id;
defaultName = "Điểm kết thúc";
locationType = 'MOVE'; // Điểm kết thúc cũng liên quan đến di chuyển
}
if (action.startsWith('ADD_TO_LEG_')) {
targetLegId = action.replace('ADD_TO_LEG_', '');
// Loại địa điểm mặc định vẫn là VISIT nếu thêm vào chặng cụ thể
}
// Bước 2: Gửi request đến dịch vụ bản đồ để phân tích tọa độ thành tên địa điểm cụ thể
let detectedName = "";
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
const data = await res.json();
const addr = data.address;
// Ưu tiên lấy tên Location/Tòa nhà/Tên đường, bỏ qua Tỉnh/Thành phố nếu có thông tin chi tiết hơn
detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
addr.shop || addr.office || addr.leisure || addr.attraction ||
addr.road || addr.neighbourhood || addr.suburb ||
data.display_name?.split(',')[0] || "";
console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`);
} catch (e) {
console.warn("[FRONTEND] Reverse Geocoding failed:", e);
}
try {
if (action === 'START') {
// Bước 3: Mutate State & UI - Đặt startLocationName = resolvedPlaceName
const resolvedPlaceName = detectedName || "Điểm xuất phát";
console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng);
await updateTourStartPoint(currentTour.id, {
name: resolvedPlaceName,
latitude: latlng.lat,
longitude: latlng.lng,
});
console.log("[FRONTEND] START point updated successfully.");
// Giao diện Top Banner và Ghim màu xanh sẽ tự động cập nhật
// khi store fetch lại dữ liệu tour và re-render.
} else if (action === 'END') {
// Bước 2: Thiết lập Điểm kết thúc
const finalName = detectedName || "Điểm kết thúc";
await updateTourEndPoint(currentTour.id, {
name: finalName,
latitude: latlng.lat,
longitude: latlng.lng,
});
} else {
// Đối với việc thêm địa điểm vào chặng, vẫn sử dụng Prompt để người dùng đặt tên theo ý muốn
const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName);
if (!name) return;
await addLocation(currentTour.id, {
name,
address: '',
latitude: latlng.lat,
longitude: latlng.lng,
legId: targetLegId,
type: locationType as any,
});
}
} catch (error: any) {
// Xử lý lỗi từ API (Ví dụ: Tour chưa có chặng nào)
if (error.message.includes('Không tìm thấy chặng')) {
alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc.");
} else {
alert("Đã xảy ra lỗi: " + error.message);
}
}
};
// 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
const startPoint = legs[0]?.locations[0];
const lastLeg = legs[legs.length - 1];
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
const tabs = [
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
].filter(t => t.visible);
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
const travelQuotes = [
"Đừng nghe họ nói, hãy tự mình đi xem.",
"Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.",
"Hành trình ngàn dặm bắt đầu từ một bước chân.",
"Đi là để trở về, nhưng với một tâm hồn mới."
];
const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []);
const tourInfo = {
title: currentTour?.title || "Hành trình khám phá TP.HCM",
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
membersCount: currentTour?.participants?.length || 0,
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
};
return (
<div className="min-h-screen bg-gray-50 pb-20">
{/* Top Navigation Bar */}
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" />
</button>
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
{tourInfo.title}
</h1>
<div className="w-10" /> {/* Spacer */}
</div>
{/* Tour Header Info */}
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
<img
src={tourInfo.coverImage}
className="absolute inset-0 w-full h-full object-cover opacity-60"
alt="Tour Cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="relative z-10 p-6 text-white pt-28 pb-20">
<div className="max-w-2xl mx-auto space-y-4">
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
{/* Dòng tóm tắt Lộ trình */}
<div className="mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full">
<span className="text-white/60 mr-1">Lộ trình:</span>
<span className="text-blue-300">Điểm xuất phát:</span>
<span className="ml-1 text-white banner-location-text" title={startPoint?.name}>{startPoint?.name || '...'}</span>
<span className="mx-2 text-white/30">-</span>
<span className="text-green-300">Điểm kết thúc:</span>
<span className="ml-1 text-white banner-location-text" title={endPoint?.name}>{endPoint?.name || '...'}</span>
</div>
<div className="flex flex-wrap gap-4 text-sm font-medium opacity-90">
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
<Calendar className="w-4 h-4 mr-1.5" />
{tourInfo.date}
</div>
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
<Users className="w-4 h-4 mr-1.5" />
{tourInfo.membersCount} thành viên
</div>
</div>
{/* Member Avatars Stack */}
<div className="flex items-center gap-2 mt-4">
<div className="flex -space-x-3">
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
<div key={i} className="w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg">
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
</div>
))}
{tourInfo.membersCount > 5 && (
<div className="w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg">
+{tourInfo.membersCount - 5}
</div>
)}
</div>
<button
onClick={() => {
if (!currentTour) return;
if (canEdit) setIsAddMemberOpen(true);
}}
disabled={!canEdit}
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
canEdit ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'
}`}
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
{/* Financial Quick-View Widget or Quote */}
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
<div className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`}>
<div className="flex justify-between items-center">
{hasFinanceAccess ? (
<>
<div>
<p className="text-indigo-100 text-xs font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại</p>
<h3 className="text-3xl font-black">{tourInfo.budget}</h3>
</div>
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
</>
) : (
<div className="flex items-start gap-4 py-2">
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
</div>
)}
</div>
</div>
</div>
{/* Khối hiển thị Điểm đầu & Điểm cuối (Dưới Financial Quick-View) */}
<div className="max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
{startPoint && (
<div className="bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner">
<MapPin className="w-5 h-5" />
</div>
<div className="overflow-hidden">
<p className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5">Điểm xuất phát</p>
<p className="text-sm font-bold text-gray-800 truncate">{startPoint.name}</p>
</div>
</div>
)}
{endPoint && (
<div className="bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner">
<Flag className="w-5 h-5" />
</div>
<div className="overflow-hidden">
<p className="text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5">Điểm kết thúc</p>
<p className="text-sm font-bold text-gray-800 truncate">{endPoint.name}</p>
</div>
</div>
)}
</div>
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`}>
{/* Tab Switcher */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
}`}
>
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}`} />
{tab.label}
</button>
))}
</div>
{/* Tab Panels */}
<div className="transition-opacity duration-300">
{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">
<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'}`}
>
<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'}`}
>
<MapIconLucide className="w-3.5 h-3.5" /> Bản đ
</button>
</div>
</div>
{viewMode === 'timeline' ? (
<ItineraryTimeline onAddLocation={(legId) => {
setTargetLegId(legId);
setEditingLocation(null);
setIsAddLocationOpen(true);
}} onEditLocation={(loc) => {
setEditingLocation(loc);
setTargetLegId(loc.legId);
setMapCenter([loc.latitude, loc.longitude]);
setIsAddLocationOpen(true);
}} />
) : (
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
<MapContainer
center={initialViewState?.center || mapCenter}
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
{canEdit && <MapContextMenu onAction={handleMapAction} />}
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
<MapTourBounds locations={allLocations} />
{/* Vẽ đường Polyline nối các điểm - Liên tục toàn bộ lộ trình xuyên suốt các chặng */}
{allLocations.length > 1 && (
<Polyline
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
color="#3b82f6"
weight={3}
dashArray="5, 10"
smoothFactor={1.5}
/>
)}
<MarkerClusterGroup chunkedLoading>
{legs.flatMap(l => l.locations).map((loc: any) => {
const isStart = startPoint?.id === loc.id;
const isEnd = endPoint?.id === loc.id;
// Sử dụng các icon tĩnh đã định nghĩa ở trên
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
return (
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
<Popup>
<div className="font-bold">{loc.name}</div>
<div className="text-xs text-gray-500">{loc.type}</div>
</Popup>
</Marker>
);
})}
</MarkerClusterGroup>
</MapContainer>
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải đ ghim đa điểm
</div>
</div>
)}
</div>
)}
{activeTab === 'expense' && (
<div className="animate-in fade-in slide-in-from-bottom-4">
<ExpenseManager />
</div>
)}
{activeTab === 'photo' && (
<div className="grid grid-cols-3 gap-1.5 animate-in fade-in">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white">
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<img
src={`https://picsum.photos/seed/${i + 10}/400/400`}
alt="Tour photo"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
</div>
))}
</div>
)}
{activeTab === 'settings' && (
<div className="p-8 text-center bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95">
<Settings className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 font-medium">Tính năng quản thành viên đang đưc cập nhật...</p>
</div>
)}
</div>
</div>
{/* Floating Action Button (Mobile) */}
{canEdit && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<button
onClick={() => {
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
setEditingLocation(null);
if (activeTab === 'plan') setIsAddLocationOpen(true);
}}
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
</button>
</div>
)}
{/* Add Member Modal */}
{currentTour && (
<AddMemberModal
isOpen={isAddMemberOpen}
onClose={() => setIsAddMemberOpen(false)}
tourId={currentTour.id}
participants={currentTour.participants || []}
onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
onMemberAdded={() => fetchTour(currentTour.id)}
/>
)}
{/* Add Location Modal */}
{currentTour && (
<AddLocationModal
isOpen={isAddLocationOpen}
onClose={() => setIsAddLocationOpen(false)}
initialLegId={targetLegId || undefined}
editingLocation={editingLocation}
tourId={currentTour.id}
/>
)}
</div>
);
};
+134
View File
@@ -0,0 +1,134 @@
# To AI Agent: Audit Location Lock Bug and Refactor GPS Tracking into a Toggle Stateful Button
## 1. Context & Problem Statement
Currently, in our travel planner application map engine (on pages like `TourNavigationPage.tsx`, `LocationNavigationModal.tsx`, or map utilities), the viewport is continuously forced to lock onto the user's live GPS position. This architecture severely damages mobile UX because:
1. It prevents users from manually dragging, panning, or scouting other areas of the map terrain.
2. There is no control interface to temporarily mute or disable live GPS tracking.
**Objective:** - Run a global audit across the entire codebase to locate functions driving this forced-center trap (e.g., custom hooks, `requestGpsPosition`, native geolocation callbacks, or reactive map state updates).
- Refactor the logic so that live tracking is bound strictly to an independent toggle state button. The map must **ONLY** lock/re-center on the user's coordinates when this tracking toggle button is actively switched **ON**.
---
## 2. Phase 1: Codebase Audit Plan (Where to Search)
Scan the entire project repository (specifically `/frontend/src`) using code search patterns to intercept the lock mechanism. Target the following files and keywords:
### Key Target Files to Inspect:
- `frontend/src/pages/TourNavigationPage.tsx`
- `frontend/src/components/LocationNavigationModal.tsx`
- Any custom hooks or contexts handling geography, such as `useGeolocation.ts`, `useMap.ts`, or generic map setup wrappers.
### Regex & Keyword Global Search Queries:
- Search for native background watchers: `navigator.geolocation.watchPosition` or `navigator.geolocation.getCurrentPosition`
- Search for custom map-centering loops: `requestGpsPosition`, `followUser`, `centerToUser`
- Search for viewport mutation commands specific to our active map engine stack:
- **Leaflet:** `.setView(`, `.panTo(`, `center={`
- **Mapbox GL JS:** `.flyTo(`, `.easeTo(`, `.jumpTo(`
---
## 3. Phase 2: Technical Refactoring Blueprint
Once the tracking logic code blocks are isolated from Phase 1, implement the structural state safety rails below:
### Step 1: Initialize the Tracking State Guard
Introduce a state hook controller (`isTrackingLocation`) to manage whether the view should actively mirror device coordinates:
```typescript
// Add inside the map controller/page container component
const [isTrackingLocation, setIsTrackingLocation] = useState(false);
const watchIdRef = useRef<number | null>(null);
### Step 2: Encapsulate the Geolocation Watcher Handler
Wrap your positioning tracking engine loop inside a conditional check governed directly by the state guard. Ensure that if the tracking state is disabled, the background watcher cleanly unmounts:
useEffect(() => {
if (isTrackingLocation) {
if (navigator.geolocation) {
console.log("GPS Location Tracking engaged. Syncing viewport to center...");
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude, heading } = position.coords;
if (mapRef.current) {
// ✅ CORRECTION: Viewport ONLY repositions center when tracking button is active
mapRef.current.easeTo({
center: [longitude, latitude],
zoom: 16, // Lock to comfortable navigation zoom level
duration: 600
});
}
},
(error) => console.error("GPS stream tracking lost:", error),
{ enableHighAccuracy: true }
);
}
} else {
// Clean up tracking process instantly when toggled OFF
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
console.log("GPS Location Tracking disabled. Map control released to user.");
}
}
return () => {
if (watchIdRef.current !== null) navigator.geolocation.clearWatch(watchIdRef.current);
};
}, [isTrackingLocation]);
### Step 3: Implement Gesture Detection (UX Safety Rail)
If the user manually drags the screen while tracking is active, the tracking state must automatically toggle OFF so the viewport doesn't fight against the user's finger movements:
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
const breakTrackingOnGesture = () => {
if (isTrackingLocation) {
console.log("User touch map interaction detected. Disengaging auto-center lock.");
setIsTrackingLocation(false); // Automatically drop tracking flag on map pan/zoom
}
};
map.on('dragstart', breakTrackingOnGesture);
map.on('zoomstart', breakTrackingOnGesture);
map.on('movestart', breakTrackingOnGesture);
return () => {
map.off('dragstart', breakTrackingOnGesture);
map.off('zoomstart', breakTrackingOnGesture);
map.off('movestart', breakTrackingOnGesture);
};
}, [isTrackingLocation]);
### Step 4: Render the UI Toggle Button UI Component
Deploy a new independent floating button on top of the map canvas workspace (placed at bottom-24 right-6, just right above your custom Compass button layout):
{/* FLOATING LIVE GPS TRACKING CENTER LOCK BUTTON */}
<button
onClick={() => setIsTrackingLocation(!isTrackingLocation)}
className={`absolute bottom-24 right-6 z-40 w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isTrackingLocation
? 'bg-green-600 border-green-400 text-white animate-pulse'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-green-500'
}`}
title={isTrackingLocation ? "Tắt tự động định tâm vị trí" : "Bật tự động định tâm theo vị trí của bạn"}
>
{/* Replace Crosshair icon element with your active layout icon package asset */}
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
## 4. Verification & Quality Acceptance Criteria
[ ] Code Erasure Verification: Confirm that old continuous loops or uncontrolled recursive .setView/.easeTo methods triggered instantly on map load are fully removed or properly contained inside the state block.
[ ] Default State Freedom: Upon opening the map page path, tracking must default to OFF. Users must be able to drag the map anywhere in the world without the screen snapped or yanked back to their physical house position.
[ ] Toggle Activation Centering: Pressing the new GPS tracking button must instantly engage the animation, center the map view directly on top of the user blue dot icon, and follow them smoothly if they move.
[ ] Manual Override Interception: Turn tracking ON. Drag the map manually with a finger gesture. Verify that the tracking button instantly changes style states back to deactivated and tracking shuts down cleanly.
-154
View File
@@ -1,154 +0,0 @@
import React, { useState, useEffect } from 'react';
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, UserPlus } from 'lucide-react';
interface UserManagementModalProps {
isOpen: boolean;
onClose: () => void;
}
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const fetchUsers = async () => {
setLoading(true);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/users`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
const data = await response.json();
setUsers(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) fetchUsers();
}, [isOpen]);
const handleToggleBlock = async (id: string) => {
try {
const API_BASE = `http://${window.location.hostname}:3001`;
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
fetchUsers();
} catch (err) {
alert('Lỗi khi thay đổi trạng thái block');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?')) return;
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/users/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
fetchUsers();
} catch (err: any) {
alert(err.message);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="w-6 h-6 text-blue-600" /> Quản người dùng
</h2>
<p className="text-sm text-gray-500">Quản trị viên quyền thêm, sửa, xóa hoặc khóa tài khoản.</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : error ? (
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold">{error}</div>
) : (
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
<th className="pb-4 font-bold px-2">Người dùng</th>
<th className="pb-4 font-bold">Vai trò</th>
<th className="pb-4 font-bold">Trạng thái</th>
<th className="pb-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{users.map(u => (
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || <User className="w-5 h-5" />}
</div>
<div>
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</div>
</div>
</td>
<td className="py-4">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
)}
</td>
<td className="py-4">
{u.isBlocked ? (
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
) : (
<span className="text-green-500 text-xs font-bold">Đang hoạt đng</span>
)}
</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleToggleBlock(u.id)}
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
>
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(u.id)}
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
title="Xóa người dùng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
};
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
+38
View File
@@ -0,0 +1,38 @@
FROM node:20-alpine AS base
# Install openssl for Prisma
RUN apk add --no-cache openssl
WORKDIR /usr/src/app
COPY package*.json ./
COPY prisma ./prisma/
# Development stage
FROM base AS development
RUN npm install
COPY . .
RUN npx prisma generate
EXPOSE 3001
CMD ["npm", "run", "start:dev"]
# Build stage for production
FROM base AS build
RUN npm install
COPY . .
RUN npx prisma generate
RUN npm run build
RUN npm prune --production
# Production stage
FROM node:20-alpine AS production
RUN apk add --no-cache openssl
WORKDIR /usr/src/app
COPY package*.json ./
COPY --from=build /usr/src/app/node_modules ./node_modules
COPY --from=build /usr/src/app/dist ./dist
COPY --from=build /usr/src/app/prisma ./prisma
EXPOSE 3001
CMD ["node", "dist/src/main.js"]
+39
View File
@@ -0,0 +1,39 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5';
const ownerUserId = '5b2053bb-f523-4a11-817c-f47fef7322bb'; // owner@travel.com
// Check if owner@travel.com is already a participant
const existing = await prisma.tourParticipant.findFirst({
where: { tourId, userId: ownerUserId }
});
if (existing) {
await prisma.tourParticipant.update({
where: { id: existing.id },
data: { role: 'OWNER' }
});
console.log('Updated existing participant to OWNER');
} else {
// Demote current owner to MEMBER or just keep them
const result = await prisma.tourParticipant.create({
data: {
tourId,
userId: ownerUserId,
role: 'OWNER'
}
});
console.log('Created new OWNER participant:', result);
}
// Update tour createdById to ownerUserId
await prisma.tour.update({
where: { id: tourId },
data: { createdById: ownerUserId }
});
console.log('Updated tour creator to owner@travel.com');
}
main().catch(console.error).finally(() => prisma.$disconnect());
+7
View File
@@ -0,0 +1,7 @@
import { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor();
onModuleInit(): Promise<void>;
onModuleDestroy(): Promise<void>;
}
+34
View File
@@ -0,0 +1,34 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PrismaService = void 0;
const common_1 = require("@nestjs/common");
const client_1 = require("@prisma/client");
const adapter_pg_1 = require("@prisma/adapter-pg");
const pg_1 = require("pg");
let PrismaService = class PrismaService extends client_1.PrismaClient {
constructor() {
console.log('--- [PRISMA CHECK] ---');
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
console.log('----------------------');
const pool = new pg_1.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new adapter_pg_1.PrismaPg(pool);
super({ adapter });
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
};
exports.PrismaService = PrismaService;
exports.PrismaService = PrismaService = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [])
], PrismaService);
//# sourceMappingURL=prisma.service.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../../prisma/prisma.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA2E;AAC3E,2CAA8C;AAC9C,mDAA8C;AAC9C,2BAA0B;AAGnB,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,qBAAY;IAC7C;QACE,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,yBAAyB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,sBAAsB,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC;QACjH,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,MAAM,IAAI,GAAG,IAAI,SAAI,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;QACtE,MAAM,OAAO,GAAG,IAAI,qBAAQ,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;IACrB,CAAC;IAED,KAAK,CAAC,YAAY,KAAK,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IAC/C,KAAK,CAAC,eAAe,KAAK,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;CACtD,CAAA;AAZY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;;GACA,aAAa,CAYzB"}
+7
View File
@@ -0,0 +1,7 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export declare class AdminGuard implements CanActivate {
private prisma;
constructor(prisma: PrismaService);
canActivate(context: ExecutionContext): Promise<boolean>;
}
+40
View File
@@ -0,0 +1,40 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AdminGuard = void 0;
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("../../prisma/prisma.service");
let AdminGuard = class AdminGuard {
constructor(prisma) {
this.prisma = prisma;
}
async canActivate(context) {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.id) {
throw new common_1.ForbiddenException('Yêu cầu xác thực không hợp lệ. Vui lòng đăng nhập.');
}
const dbUser = await this.prisma.user.findUnique({
where: { id: user.id },
select: { isAdmin: true, isBlocked: true },
});
if (!dbUser || !dbUser.isAdmin || dbUser.isBlocked) {
throw new common_1.ForbiddenException('Truy cập bị từ chối. Bạn không có quyền quản trị viên hệ thống.');
}
return true;
}
};
exports.AdminGuard = AdminGuard;
exports.AdminGuard = AdminGuard = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], AdminGuard);
//# sourceMappingURL=admin.guard.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"admin.guard.js","sourceRoot":"","sources":["../../../src/auth/admin.guard.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,gEAA4D;AAGrD,IAAM,UAAU,GAAhB,MAAM,UAAU;IACrB,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;QAGpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;YACtB,MAAM,IAAI,2BAAkB,CAAC,oDAAoD,CAAC,CAAC;QACrF,CAAC;QAGD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC/C,KAAK,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE;YACtB,MAAM,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;SAC3C,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;YACnD,MAAM,IAAI,2BAAkB,CAAC,iEAAiE,CAAC,CAAC;QAClG,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAzBY,gCAAU;qBAAV,UAAU;IADtB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,UAAU,CAyBtB"}
+8
View File
@@ -0,0 +1,8 @@
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
export declare class JwtAuthGuard extends JwtAuthGuard_base {
}
declare const JwtAuthGuardNoAnonymous_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
export declare class JwtAuthGuardNoAnonymous extends JwtAuthGuardNoAnonymous_base {
handleRequest(err: any, user: any, info: any): any;
}
export {};
+34
View File
@@ -0,0 +1,34 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.JwtAuthGuardNoAnonymous = exports.JwtAuthGuard = void 0;
const common_1 = require("@nestjs/common");
const passport_1 = require("@nestjs/passport");
const common_2 = require("@nestjs/common");
let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') {
};
exports.JwtAuthGuard = JwtAuthGuard;
exports.JwtAuthGuard = JwtAuthGuard = __decorate([
(0, common_1.Injectable)()
], JwtAuthGuard);
let JwtAuthGuardNoAnonymous = class JwtAuthGuardNoAnonymous extends (0, passport_1.AuthGuard)('jwt') {
handleRequest(err, user, info) {
if (err || !user) {
throw err || new common_2.UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
}
if (user.isAnonymous) {
throw new common_2.UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
}
return user;
}
};
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous;
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous = __decorate([
(0, common_1.Injectable)()
], JwtAuthGuardNoAnonymous);
//# sourceMappingURL=jwt-auth.guard.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAC7C,2CAAuD;AAGhD,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B;AAI9C,IAAM,uBAAuB,GAA7B,MAAM,uBAAwB,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;IAC3D,aAAa,CAAC,GAAQ,EAAE,IAAS,EAAE,IAAS;QAC1C,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YACjB,MAAM,GAAG,IAAI,IAAI,8BAAqB,CAAC,6CAA6C,CAAC,CAAC;QACxF,CAAC;QAGD,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YACrB,MAAM,IAAI,8BAAqB,CAAC,gGAAgG,CAAC,CAAC;QACpI,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAbY,0DAAuB;kCAAvB,uBAAuB;IADnC,IAAA,mBAAU,GAAE;GACA,uBAAuB,CAanC"}
+20
View File
@@ -0,0 +1,20 @@
import { PrismaService } from '../../prisma/prisma.service';
declare const JwtStrategy_base: new (...args: any) => any;
export declare class JwtStrategy extends JwtStrategy_base {
private prisma;
constructor(prisma: PrismaService);
validate(payload: any): Promise<{
id: string;
email: string | null;
passwordHash: string | null;
name: string | null;
phone: string | null;
address: string | null;
avatar: string | null;
createdAt: Date;
isAdmin: boolean;
isBlocked: boolean;
isAnonymous: boolean;
}>;
}
export {};
+41
View File
@@ -0,0 +1,41 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.JwtStrategy = void 0;
const common_1 = require("@nestjs/common");
const passport_1 = require("@nestjs/passport");
const passport_jwt_1 = require("passport-jwt");
const prisma_service_1 = require("../../prisma/prisma.service");
let JwtStrategy = class JwtStrategy extends (0, passport_1.PassportStrategy)(passport_jwt_1.Strategy) {
constructor(prisma) {
super({
jwtFromRequest: passport_jwt_1.ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'super-secret',
});
this.prisma = prisma;
}
async validate(payload) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user) {
throw new common_1.UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
}
return user;
}
};
exports.JwtStrategy = JwtStrategy;
exports.JwtStrategy = JwtStrategy = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], JwtStrategy);
//# sourceMappingURL=jwt.strategy.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAID,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAtBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAsBvB"}
+10
View File
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Cache } from 'cache-manager';
import { HttpAdapterHost } from '@nestjs/core';
export declare class CompressCacheInterceptor implements NestInterceptor {
private cacheManager;
private readonly httpAdapterHost;
constructor(cacheManager: Cache, httpAdapterHost: HttpAdapterHost);
intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>>;
}
+123
View File
@@ -0,0 +1,123 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CompressCacheInterceptor = void 0;
const common_1 = require("@nestjs/common");
const rxjs_1 = require("rxjs");
const operators_1 = require("rxjs/operators");
const cache_manager_1 = require("@nestjs/cache-manager");
const zlib = __importStar(require("zlib"));
const util_1 = require("util");
const core_1 = require("@nestjs/core");
const gzip = (0, util_1.promisify)(zlib.gzip);
const gunzip = (0, util_1.promisify)(zlib.gunzip);
const COMPRESSION_THRESHOLD = 100;
let CompressCacheInterceptor = class CompressCacheInterceptor {
constructor(cacheManager, httpAdapterHost) {
this.cacheManager = cacheManager;
this.httpAdapterHost = httpAdapterHost;
}
async intercept(context, next) {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const request = context.getArgByIndex(0);
const response = context.getArgByIndex(1);
if (httpAdapter.getRequestMethod(request) !== 'GET') {
return next.handle();
}
const cacheKey = httpAdapter.getRequestUrl(request);
let cachedData = await this.cacheManager.get(cacheKey);
if (cachedData) {
try {
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
const decompressed = await gunzip(cachedData);
const jsonString = decompressed.toString('utf8');
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
return (0, rxjs_1.of)(JSON.parse(jsonString));
}
else {
const jsonString = cachedData.toString();
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
return (0, rxjs_1.of)(JSON.parse(jsonString));
}
}
catch (e) {
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
await this.cacheManager.del(cacheKey);
}
}
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
return next.handle().pipe((0, operators_1.tap)(async (data) => {
if (!data)
return;
const jsonString = JSON.stringify(data);
const ttl = 60000;
if (jsonString.length > COMPRESSION_THRESHOLD) {
try {
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
await this.cacheManager.set(cacheKey, compressed, ttl);
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
}
catch (e) {
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
}
else {
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
}));
}
};
exports.CompressCacheInterceptor = CompressCacheInterceptor;
exports.CompressCacheInterceptor = CompressCacheInterceptor = __decorate([
(0, common_1.Injectable)(),
__param(0, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [Object, core_1.HttpAdapterHost])
], CompressCacheInterceptor);
//# sourceMappingURL=compress-cache.interceptor.js.map
@@ -0,0 +1 @@
{"version":3,"file":"compress-cache.interceptor.js","sourceRoot":"","sources":["../../../src/common/compress-cache.interceptor.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAAoG;AACpG,+BAAsC;AACtC,8CAAqC;AACrC,yDAAsD;AAEtD,2CAA6B;AAC7B,+BAAiC;AACjC,uCAA+C;AAG/C,MAAM,IAAI,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAClC,MAAM,MAAM,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAItC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAG3B,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IACnC,YACiC,YAAmB,EACjC,eAAgC;QADlB,iBAAY,GAAZ,YAAY,CAAO;QACjC,oBAAe,GAAf,eAAe,CAAiB;IAChD,CAAC;IAEJ,KAAK,CAAC,SAAS,CAAC,OAAyB,EAAE,IAAiB;QAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC;QACrD,MAAM,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QAG1C,IAAI,WAAW,CAAC,gBAAgB,CAAC,OAAO,CAAC,KAAK,KAAK,EAAE,CAAC;YACpD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC;QACvB,CAAC;QAED,MAAM,QAAQ,GAAG,WAAW,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QACpD,IAAI,UAAU,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAS,QAAQ,CAAC,CAAC;QAE/D,IAAI,UAAU,EAAE,CAAC;YACf,IAAI,CAAC;gBAEH,IAAI,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC7G,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;oBAC9C,MAAM,UAAU,GAAG,YAAY,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;oBAEjD,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,kBAAkB,CAAC,CAAC;oBAC/D,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;qBAAM,CAAC;oBAEN,MAAM,UAAU,GAAG,UAAU,CAAC,QAAQ,EAAE,CAAC;oBACzC,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;oBACjE,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;YACH,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,OAAO,CAAC,KAAK,CAAC,8CAA8C,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;gBAE5E,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACxC,CAAC;QACH,CAAC;QAID,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;QAEnD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CACvB,IAAA,eAAG,EAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YACjB,IAAI,CAAC,IAAI;gBAAE,OAAO;YAElB,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,KAAK,CAAC;YAElB,IAAI,UAAU,CAAC,MAAM,GAAG,qBAAqB,EAAE,CAAC;gBAC9C,IAAI,CAAC;oBACH,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC;oBAC/D,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;oBACvD,OAAO,CAAC,GAAG,CAAC,kCAAkC,QAAQ,KAAK,UAAU,CAAC,MAAM,OAAO,UAAU,CAAC,MAAM,SAAS,CAAC,CAAC;gBAEjH,CAAC;gBAAC,OAAO,CAAC,EAAE,CAAC;oBACX,OAAO,CAAC,KAAK,CAAC,mCAAmC,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;oBAEjE,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;gBACzD,CAAC;YACH,CAAC;iBAAM,CAAC;gBAEN,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;YACzD,CAAC;QACH,CAAC,CAAC,CACH,CAAC;IACJ,CAAC;CACF,CAAA;AAxEY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,mBAAU,GAAE;IAGR,WAAA,IAAA,eAAM,EAAC,6BAAa,CAAC,CAAA;6CACY,sBAAe;GAHxC,wBAAwB,CAwEpC"}
+7
View File
@@ -0,0 +1,7 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export declare class TourRoleGuard implements CanActivate {
private prisma;
constructor(prisma: PrismaService);
canActivate(context: ExecutionContext): Promise<boolean>;
}
+58
View File
@@ -0,0 +1,58 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TourRoleGuard = void 0;
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("../../prisma/prisma.service");
let TourRoleGuard = class TourRoleGuard {
constructor(prisma) {
this.prisma = prisma;
}
async canActivate(context) {
const request = context.switchToHttp().getRequest();
const user = request.user;
const tourId = request.params.id || request.params.tourId;
const path = request.url;
if (!user || !tourId) {
throw new common_1.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 || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
request.tourParticipation = null;
return true;
}
const participation = await this.prisma.tourParticipant.findUnique({
where: {
tourId_userId: {
tourId: tourId,
userId: user.id,
},
},
});
if (!participation) {
throw new common_1.ForbiddenException("Bạn không phải là thành viên của tour này.");
}
request.tourParticipation = participation;
const role = participation.role;
const isPlanPath = path.includes('/plans');
const isExpensePath = path.includes('/expenses');
if ((role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
(isPlanPath || isExpensePath)) {
throw new common_1.ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
}
return true;
}
};
exports.TourRoleGuard = TourRoleGuard;
exports.TourRoleGuard = TourRoleGuard = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], TourRoleGuard);
//# sourceMappingURL=rbac.middleware.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../../../src/common/rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,gEAA4D;AAGrD,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,2BAAkB,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,2BAAkB,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,2BAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA/CY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,aAAa,CA+CzB"}
+35
View File
@@ -0,0 +1,35 @@
import 'reflect-metadata';
import { OnGatewayConnection } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { PrismaService } from '../prisma/prisma.service';
import { ParticipantRole } from '@prisma/client';
import { Reflector } from '@nestjs/core';
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { Cache } from 'cache-manager';
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
handleJoinPhoto(client: Socket, photoId: string): void;
notifyNewComment(tourId: string, data: any): void;
notifyNewPhotoComment(photoId: string, data: any): void;
handleJoinUser(client: Socket, userId: string): void;
notifyNewMessage(receiverId: string, data: any): void;
notifyConnectionAccepted(requesterId: string, data: any): void;
notifyJoinRequestAccepted(userId: string, data: any): void;
}
export declare const ROLES_KEY = "roles";
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
export declare class TourRoleGuard implements CanActivate {
private reflector;
private prisma;
private cacheManager;
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
canActivate(context: ExecutionContext): Promise<boolean>;
}
export declare class EmailService {
private transporter;
constructor();
sendOTP(email: string, otp: string): Promise<any>;
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
}
+4963
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -1,8 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": ".",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": ["**/*.prisma", "schema.sql"],
"watchAssets": true
}
}
+80
View File
@@ -0,0 +1,80 @@
> backend@0.0.1 start:dev
> nest start --watch
[10:46:05 PM] Starting compilation in watch mode...
[10:46:08 PM] Found 0 errors. Watching for file changes.
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestFactory] Starting Nest application...
--- [PRISMA CHECK] ---
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
----------------------
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +40ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] JwtModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] CacheModule dependencies initialized +15ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] AppModule dependencies initialized +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinTour" message +11ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinPhoto" message +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AppController {/api/v1}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1, GET} route +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AuthController {/api/v1/auth}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/convert-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/create-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/status, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/login, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/request, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicTourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id/public, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] TourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/locations, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/start-point, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/end-point, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs/batch, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/explore, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members/:userId, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/photos, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] UserController {/api/v1/users}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/me/photos, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/block/:id, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] RoutingController {/api/v1/routing}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/routing/optimize/:legId, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LegController {/api/v1/legs}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LocationController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, DELETE} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] CommentController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PhotoController {/api/v1/photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/upload-anonymous, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicPhotoController {/api/v1/public-photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AdminOtpController {/api/v1/admin/otp}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/send, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestApplication] Nest application successfully started +16ms
🚀 Server is running on: http://localhost:3001
[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.
[WS] Client connected: sIV1IoQvXb-xaa9iAAAC
[WS] Client sIV1IoQvXb-xaa9iAAAC joined room: photo_557913e6-fcb4-4dae-8cff-a6ff52da3386
+50
View File
@@ -0,0 +1,50 @@
{
"name": "backend",
"version": "0.0.1",
"scripts": {
"build": "nest build",
"start:dev": "nest start --watch",
"db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma",
"db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma",
"db:seed": "dotenv -e ../.env -- tsx seed.ts"
},
"devDependencies": {
"@nestjs/cli": "^11.0.23",
"@types/bcrypt": "^5.0.2",
"@types/node": "^20.14.10",
"@types/pg": "^8.11.6",
"dotenv-cli": "^7.4.2",
"prisma": "^5.16.2",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.5.3"
},
"dependencies": {
"@nestjs/cache-manager": "^3.1.3",
"@nestjs/common": "^11.1.27",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.27",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.27",
"@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/websockets": "^11.1.27",
"@prisma/adapter-pg": "^5.16.2",
"@prisma/client": "^5.16.2",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2",
"exifr": "^7.1.3",
"heic-convert": "^2.1.0",
"nodemailer": "^9.0.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.12.0",
"redis": "^6.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sharp": "^0.35.1",
"socket.io": "^4.8.3"
}
}
@@ -0,0 +1,174 @@
-- CreateEnum
CREATE TYPE "ParticipantRole" AS ENUM ('OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY');
-- CreateEnum
CREATE TYPE "JoinRequestStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ExpenseCategory" AS ENUM ('ACCOMMODATION', 'FOOD', 'TRANSPORT', 'TICKET', 'OTHER');
-- CreateEnum
CREATE TYPE "LocationStatus" AS ENUM ('PENDING', 'COMPLETED');
-- CreateEnum
CREATE TYPE "LocationType" AS ENUM ('MOVE', 'VISIT', 'REST', 'EAT');
-- CreateEnum
CREATE TYPE "PrivacyLevel" AS ENUM ('PUBLIC', 'TOUR_ONLY', 'PRIVATE');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT,
"phone" TEXT,
"address" TEXT,
"avatar" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tour" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"totalCost" DECIMAL(15,2) NOT NULL DEFAULT 0,
"createdById" TEXT NOT NULL,
CONSTRAINT "Tour_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JoinRequest" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"status" "JoinRequestStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "JoinRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourParticipant" (
"tourId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("tourId","userId")
);
-- CreateTable
CREATE TABLE "Leg" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"note" TEXT,
CONSTRAINT "Leg_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Location" (
"id" TEXT NOT NULL,
"legId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"plannedStart" TIMESTAMP(3),
"plannedEnd" TIMESTAMP(3),
"actualStart" TIMESTAMP(3),
"actualEnd" TIMESTAMP(3),
"status" "LocationStatus" NOT NULL DEFAULT 'PENDING',
"type" "LocationType" NOT NULL DEFAULT 'VISIT',
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Expense" (
"id" TEXT NOT NULL,
"leg_id" TEXT NOT NULL,
"location_id" TEXT,
"category" "ExpenseCategory" NOT NULL,
"amount" DECIMAL(15,2) NOT NULL,
"description" TEXT,
"note" TEXT,
"paid_by_id" TEXT,
CONSTRAINT "Expense_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Photo" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"locationId" TEXT,
"uploaderId" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"metadata" JSONB,
"privacy" "PrivacyLevel" NOT NULL DEFAULT 'TOUR_ONLY',
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "JoinRequest_tourId_status_idx" ON "JoinRequest"("tourId", "status");
-- CreateIndex
CREATE INDEX "JoinRequest_userId_idx" ON "JoinRequest"("userId");
-- AddForeignKey
ALTER TABLE "Tour" ADD CONSTRAINT "Tour_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Leg" ADD CONSTRAINT "Leg_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Location" ADD CONSTRAINT "Location_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_leg_id_fkey" FOREIGN KEY ("leg_id") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_paid_by_id_fkey" FOREIGN KEY ("paid_by_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Leg" ADD COLUMN "description" TEXT,
ADD COLUMN "endDate" TIMESTAMP(3),
ADD COLUMN "startDate" TIMESTAMP(3);
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "childDiscount" INTEGER NOT NULL DEFAULT 30;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "description" TEXT;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"locationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "tags" TEXT[];
@@ -0,0 +1,15 @@
-- DropForeignKey
ALTER TABLE "Photo" DROP CONSTRAINT "Photo_tourId_fkey";
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "originalUrl" TEXT,
ALTER COLUMN "tourId" DROP NOT NULL,
ALTER COLUMN "imageUrl" DROP NOT NULL;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "email" DROP NOT NULL,
ALTER COLUMN "passwordHash" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "photoId" TEXT,
ALTER COLUMN "locationId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_photoId_fkey" FOREIGN KEY ("photoId") REFERENCES "Photo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Location" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "TourParticipant" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,22 @@
/*
Warnings:
- The primary key for the `TourParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint.
- A unique constraint covering the columns `[tourId,userId]` on the table `TourParticipant` will be added. If there are existing duplicate values, this will fail.
- The required column `id` was added to the `TourParticipant` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "TourParticipant" DROP CONSTRAINT "TourParticipant_pkey",
ADD COLUMN "displayName" TEXT,
ADD COLUMN "id" TEXT;
UPDATE "TourParticipant" SET "id" = md5(random()::text);
ALTER TABLE "TourParticipant" ALTER COLUMN "id" SET NOT NULL,
ALTER COLUMN "userId" DROP NOT NULL;
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("id");
-- CreateIndex
CREATE UNIQUE INDEX "TourParticipant_tourId_userId_key" ON "TourParticipant"("tourId", "userId");
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "TourInvitation" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiredAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TourInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TourInvitation_token_key" ON "TourInvitation"("token");
-- CreateIndex
CREATE UNIQUE INDEX "TourInvitation_tourId_email_key" ON "TourInvitation"("tourId", "email");
-- AddForeignKey
ALTER TABLE "TourInvitation" ADD CONSTRAINT "TourInvitation_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ConnectionType" AS ENUM ('FRIEND', 'FAMILY');
-- CreateTable
CREATE TABLE "UserConnection" (
"id" TEXT NOT NULL,
"requesterId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
"type" "ConnectionType" NOT NULL DEFAULT 'FRIEND',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DirectMessage" (
"id" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DirectMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "UserConnection_requesterId_idx" ON "UserConnection"("requesterId");
-- CreateIndex
CREATE INDEX "UserConnection_receiverId_idx" ON "UserConnection"("receiverId");
-- CreateIndex
CREATE UNIQUE INDEX "UserConnection_requesterId_receiverId_key" ON "UserConnection"("requesterId", "receiverId");
-- CreateIndex
CREATE INDEX "DirectMessage_senderId_idx" ON "DirectMessage"("senderId");
-- CreateIndex
CREATE INDEX "DirectMessage_receiverId_idx" ON "DirectMessage"("receiverId");
-- AddForeignKey
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
-- AlterTable
ALTER TABLE "DirectMessage" ADD COLUMN "attachmentUrl" TEXT,
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;
-- CreateTable
CREATE TABLE "TourMessage" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"attachmentUrl" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TourMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TourMessage_tourId_idx" ON "TourMessage"("tourId");
-- CreateIndex
CREATE INDEX "TourMessage_senderId_idx" ON "TourMessage"("senderId");
-- AddForeignKey
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,73 @@
-- CreateTable
CREATE TABLE "WordFilter" (
"id" TEXT NOT NULL,
"word" TEXT NOT NULL,
"replacement" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WordFilter_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModerationSetting" (
"id" TEXT NOT NULL,
"blockNsfw" BOOLEAN NOT NULL DEFAULT false,
"blurFaces" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "ModerationSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourRating" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"raterUserId" TEXT NOT NULL,
"honesty" INTEGER NOT NULL DEFAULT 5,
"transparency" INTEGER NOT NULL DEFAULT 5,
"enthusiasm" INTEGER NOT NULL DEFAULT 5,
"cheerfulness" INTEGER NOT NULL DEFAULT 5,
"seriousness" INTEGER NOT NULL DEFAULT 5,
"planning" INTEGER NOT NULL DEFAULT 5,
"survival" INTEGER NOT NULL DEFAULT 5,
"averageScore" DOUBLE PRECISION NOT NULL DEFAULT 5.0,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TourRating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourShare" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TourShare_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WordFilter_word_key" ON "WordFilter"("word");
-- CreateIndex
CREATE UNIQUE INDEX "TourRating_tourId_targetUserId_raterUserId_key" ON "TourRating"("tourId", "targetUserId", "raterUserId");
-- CreateIndex
CREATE UNIQUE INDEX "TourShare_tourId_key" ON "TourShare"("tourId");
-- CreateIndex
CREATE UNIQUE INDEX "TourShare_token_key" ON "TourShare"("token");
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_raterUserId_fkey" FOREIGN KEY ("raterUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourShare" ADD CONSTRAINT "TourShare_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "BusinessReport" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"email" TEXT,
"address" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"reason" TEXT NOT NULL,
"isBlacklisted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BusinessReport_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,49 @@
-- AlterTable
ALTER TABLE "ModerationSetting" ADD COLUMN "trashRetentionDays" INTEGER NOT NULL DEFAULT 30;
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "TourNote" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "TourNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RecommendedLocation" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"email" TEXT,
"address" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"description" TEXT NOT NULL,
"stars" INTEGER NOT NULL DEFAULT 5,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RecommendedLocation_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "flaggedAt" TIMESTAMP(3),
ADD COLUMN "flaggedReason" TEXT,
ADD COLUMN "isFlagged" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
@@ -6,6 +6,9 @@ import { Pool } from 'pg';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
console.log('--- [PRISMA CHECK] ---');
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
console.log('----------------------');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
super({ adapter });
+402
View File
@@ -0,0 +1,402 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// --- Enums ---
enum ParticipantRole {
OWNER
MANAGER
MEMBER
MEMBER_NO_FINANCE
VIEWER_ONLY
}
enum JoinRequestStatus {
PENDING
ACCEPTED
REJECTED
}
enum ExpenseCategory {
ACCOMMODATION
FOOD
TRANSPORT
TICKET
OTHER
}
enum LocationStatus {
PENDING
COMPLETED
}
enum LocationType {
MOVE
VISIT
REST
EAT
}
enum PrivacyLevel {
PUBLIC
TOUR_ONLY
PRIVATE
}
// --- Models ---
model User {
id String @id @default(uuid())
email String? @unique
passwordHash String?
name String?
phone String?
address String?
avatar String?
createdAt DateTime @default(now())
isAdmin Boolean @default(false)
isBlocked Boolean @default(false)
isAnonymous Boolean @default(false)
createdTours Tour[] @relation("TourCreator")
tourParticipations TourParticipant[]
requestedJoinRequests JoinRequest[] @relation("JoinRequestUser")
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
uploadedPhotos Photo[]
paidExpenses Expense[] @relation("ExpensePaidBy")
comments Comment[]
sentConnections UserConnection[] @relation("ConnectionRequester")
receivedConnections UserConnection[] @relation("ConnectionReceiver")
sentMessages DirectMessage[] @relation("MessageSender")
receivedMessages DirectMessage[] @relation("MessageReceiver")
tourMessages TourMessage[]
receivedRatings TourRating[] @relation("RatedUser")
sentRatings TourRating[] @relation("RatingUser")
tourNotes TourNote[]
}
model Tour {
id String @id @default(uuid())
title String
description String?
startDate DateTime?
endDate DateTime?
createdAt DateTime @default(now())
totalCost Decimal @default(0) @db.Decimal(15, 2)
adultCount Int @default(1)
childCount Int @default(0)
childDiscount Int @default(30)
tags String[]
createdById String
creator User @relation("TourCreator", fields: [createdById], references: [id])
participants TourParticipant[]
joinRequests JoinRequest[]
legs Leg[]
photos Photo[]
invitations TourInvitation[]
tourMessages TourMessage[]
ratings TourRating[]
share TourShare?
notes TourNote[]
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model JoinRequest {
id String @id @default(uuid())
tourId String
userId String
requestedById String
status JoinRequestStatus @default(PENDING)
createdAt DateTime @default(now())
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
user User @relation("JoinRequestUser", fields: [userId], references: [id], onDelete: Cascade)
requestedBy User @relation("JoinRequester", fields: [requestedById], references: [id], onDelete: Cascade)
@@index([tourId, status])
@@index([userId])
}
model TourParticipant {
id String @id @default(uuid())
tourId String
userId String?
role ParticipantRole @default(MEMBER)
displayName String?
adultCount Int @default(1)
childCount Int @default(0)
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([tourId, userId])
}
model Leg {
id String @id @default(uuid())
tourId String
sequence Int
note String? @db.Text
startDate DateTime?
endDate DateTime?
description String? @db.Text
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
locations Location[]
expenses Expense[]
}
model Location {
id String @id @default(uuid())
legId String
name String
address String?
latitude Float
longitude Float
plannedStart DateTime?
plannedEnd DateTime?
actualStart DateTime?
actualEnd DateTime?
status LocationStatus @default(PENDING)
type LocationType @default(VISIT)
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
expenses Expense[]
photos Photo[]
comments Comment[]
createdAt DateTime @default(now())
}
model Expense {
id String @id @default(uuid())
legId String @map("leg_id")
locationId String? @map("location_id")
category ExpenseCategory
amount Decimal @db.Decimal(15, 2)
description String? @db.Text
note String? @db.Text
paidById String? @map("paid_by_id")
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
paidBy User? @relation("ExpensePaidBy", fields: [paidById], references: [id], onDelete: SetNull)
}
model Photo {
id String @id @default(uuid())
tourId String?
locationId String?
uploaderId String
imageUrl String?
originalUrl String?
capturedAt DateTime @default(now())
metadata Json?
privacy PrivacyLevel @default(TOUR_ONLY)
isFlagged Boolean @default(false)
flaggedReason String?
flaggedAt DateTime?
tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
uploader User @relation(fields: [uploaderId], references: [id])
comments Comment[]
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model Comment {
id String @id @default(uuid())
content String
createdAt DateTime @default(now())
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
photoId String?
photo Photo? @relation(fields: [photoId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model TourInvitation {
id String @id @default(uuid())
tourId String
email String
role ParticipantRole @default(MEMBER)
token String @unique
createdAt DateTime @default(now())
expiredAt DateTime
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
@@unique([tourId, email])
}
enum ConnectionStatus {
PENDING
ACCEPTED
REJECTED
}
enum ConnectionType {
FRIEND
FAMILY
}
model UserConnection {
id String @id @default(uuid())
requesterId String
receiverId String
status ConnectionStatus @default(PENDING)
type ConnectionType @default(FRIEND)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
requester User @relation("ConnectionRequester", fields: [requesterId], references: [id], onDelete: Cascade)
receiver User @relation("ConnectionReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@unique([requesterId, receiverId])
@@index([requesterId])
@@index([receiverId])
}
model DirectMessage {
id String @id @default(uuid())
senderId String
receiverId String
content String @db.Text
attachmentUrl String?
latitude Float?
longitude Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade)
receiver User @relation("MessageReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@index([senderId])
@@index([receiverId])
}
model TourMessage {
id String @id @default(uuid())
tourId String
senderId String
content String @db.Text
attachmentUrl String?
latitude Float?
longitude Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
@@index([tourId])
@@index([senderId])
}
model WordFilter {
id String @id @default(uuid())
word String @unique
replacement String
createdAt DateTime @default(now())
}
model ModerationSetting {
id String @id @default(uuid())
blockNsfw Boolean @default(false)
blurFaces Boolean @default(false)
trashRetentionDays Int @default(30)
}
model TourRating {
id String @id @default(uuid())
tourId String
targetUserId String
raterUserId String
honesty Int @default(5)
transparency Int @default(5)
enthusiasm Int @default(5)
cheerfulness Int @default(5)
seriousness Int @default(5)
planning Int @default(5)
survival Int @default(5)
averageScore Float @default(5.0)
comment String? @db.Text
createdAt DateTime @default(now())
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
targetUser User @relation("RatedUser", fields: [targetUserId], references: [id], onDelete: Cascade)
raterUser User @relation("RatingUser", fields: [raterUserId], references: [id], onDelete: Cascade)
@@unique([tourId, targetUserId, raterUserId])
}
model TourShare {
id String @id @default(uuid())
tourId String @unique
token String @unique @default(uuid())
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
}
model BusinessReport {
id String @id @default(uuid())
type String // e.g. "USER", "RESTAURANT", "HOTEL", "HOMESTAY"
name String
phone String?
email String?
address String?
latitude Float?
longitude Float?
reason String @db.Text
isBlacklisted Boolean @default(false)
createdAt DateTime @default(now())
}
model TourNote {
id String @id @default(uuid())
tourId String
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model RecommendedLocation {
id String @id @default(uuid())
type String // e.g. "RESTAURANT", "HOTEL", "HOMESTAY"
name String
phone String?
email String?
address String?
latitude Float?
longitude Float?
description String @db.Text
stars Int @default(5)
isApproved Boolean @default(false)
createdAt DateTime @default(now())
}
+19
View File
@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
import * as path from 'path';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
const users = await prisma.user.findMany();
console.log('USERS:', users);
}
main().finally(() => pool.end());
+25 -2
View File
@@ -1,8 +1,12 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import 'dotenv/config';
import * as bcrypt from 'bcrypt';
import * as dotenv from 'dotenv';
import * as path from 'path';
import bcrypt from 'bcrypt';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
@@ -90,6 +94,25 @@ async function main() {
},
});
console.log('--- Đang tạo bình luận mẫu... ---');
const dinhDocLap = await prisma.location.findFirst({ where: { name: 'Dinh Độc Lập' } });
if (dinhDocLap) {
await prisma.comment.createMany({
data: [
{
content: 'Chỗ này rất đẹp, giàu giá trị lịch sử!',
locationId: dinhDocLap.id,
userId: owner.id,
},
{
content: 'Nên đi vào buổi sáng cho mát mẻ mọi người nhé.',
locationId: dinhDocLap.id,
userId: photoMember.id,
}
],
});
}
console.log('--- Seed dữ liệu hoàn tất! ---');
console.log(`Email đăng nhập Owner: ${owner.email}`);
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
@@ -1,5 +1,5 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AdminGuard implements CanActivate {
+23
View File
@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UnauthorizedException } from '@nestjs/common';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// Guard that rejects anonymous/guest users - used for dashboard and sensitive endpoints
@Injectable()
export class JwtAuthGuardNoAnonymous extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
}
// Reject anonymous/temporary users
if (user.isAnonymous) {
throw new UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
}
return user;
}
}
@@ -1,7 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from './prisma.service.js';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -22,6 +22,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
}
// Note: We allow anonymous users to pass JWT validation
// Individual endpoints decide whether to accept anonymous users based on their guard
return user;
}
}
@@ -0,0 +1,91 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Inject } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import * as zlib from 'zlib';
import { promisify } from 'util';
import { HttpAdapterHost } from '@nestjs/core';
// Promisify các hàm nén/giải nén
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
// Ngưỡng nén: Chỉ nén nếu chuỗi JSON lớn hơn ngưỡng này (bytes)
// Nén dữ liệu quá nhỏ có thể làm tăng kích thước do overhead của header nén
const COMPRESSION_THRESHOLD = 100;
@Injectable()
export class CompressCacheInterceptor implements NestInterceptor {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly httpAdapterHost: HttpAdapterHost, // Để truy cập request/response
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const request = context.getArgByIndex(0);
const response = context.getArgByIndex(1);
// Chỉ áp dụng cho các request GET
if (httpAdapter.getRequestMethod(request) !== 'GET') {
return next.handle();
}
const cacheKey = httpAdapter.getRequestUrl(request);
let cachedData = await this.cacheManager.get<Buffer>(cacheKey);
if (cachedData) {
try {
// Kiểm tra xem dữ liệu có phải là Buffer và có Gzip header (0x1f 0x8b) không
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
const decompressed = await gunzip(cachedData);
const jsonString = decompressed.toString('utf8');
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
return of(JSON.parse(jsonString));
} else {
// Dữ liệu không nén (lưu dưới dạng string hoặc buffer thường)
const jsonString = cachedData.toString();
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
return of(JSON.parse(jsonString));
}
} catch (e) {
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
// Nếu giải nén lỗi, coi như cache miss và xóa cache bị lỗi
await this.cacheManager.del(cacheKey);
}
}
// Cache miss hoặc giải nén lỗi, tiếp tục xử lý request
// Đặt header MISS ngay lập tức trước khi chạy logic Controller
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
return next.handle().pipe(
tap(async (data) => { // Sử dụng tap để thực hiện side effect (lưu cache) mà không thay đổi dữ liệu gốc
if (!data) return;
const jsonString = JSON.stringify(data);
const ttl = 60000; // TTL mặc định 1 phút (có thể cấu hình từ CACHE_TTL.DEFAULT)
if (jsonString.length > COMPRESSION_THRESHOLD) {
try {
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
await this.cacheManager.set(cacheKey, compressed, ttl);
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
// Không setHeader ở đây vì response có thể đã gửi xong
} catch (e) {
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
// Nếu nén lỗi, lưu dữ liệu không nén làm fallback
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
} else {
// Dữ liệu quá nhỏ, lưu không nén
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
})
);
}
}
@@ -1,5 +1,5 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class TourRoleGuard implements CanActivate {
@@ -7,9 +7,8 @@ export class TourRoleGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user; // Giả sử đã qua AuthGuard (Passport/JWT)
const user = request.user;
// UUID không cần parseInt
const tourId = request.params.id || request.params.tourId;
const path = request.url;
@@ -17,11 +16,11 @@ export class TourRoleGuard implements CanActivate {
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
}
/**
* TỐI ƯU: Chỉ truy vấn Database 1 lần duy nhất đ lấy thông tin thành viên.
* Chúng ta lưu kết quả vào request object đ các interceptor hoặc controller
* sau này thể dùng lại không cần query lại.
*/
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
request.tourParticipation = null;
return true;
}
const participation = await this.prisma.tourParticipant.findUnique({
where: {
tourId_userId: {
@@ -35,14 +34,12 @@ export class TourRoleGuard implements CanActivate {
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
}
// Gắn thông tin vào request để sử dụng ở tầng Controller
request.tourParticipation = participation;
const role = participation.role;
const isPlanPath = path.includes('/plans');
const isExpensePath = path.includes('/expenses');
// Theo định nghĩa mới: MEMBER_NO_FINANCE và VIEWER_ONLY bị hạn chế
if (
(role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
(isPlanPath || isExpensePath)
+4746
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
async function test() {
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'owner@travel.com', password: '123456' })
});
const loginData = await loginRes.json();
if (loginRes.ok) {
const token = loginData.access_token;
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Tour ID from seed
const res = await fetch(`http://localhost:3001/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ displayName: 'Offline Member X', role: 'MEMBER' })
});
console.log('Add status:', res.status);
const data = await res.json();
console.log('Add response:', data);
} else {
console.error('Login failed:', loginData);
}
}
test().catch(console.error);
+14
View File
@@ -0,0 +1,14 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const users = await prisma.user.findMany();
console.log('--- Users in DB ---');
console.log(users);
const participants = await prisma.tourParticipant.findMany();
console.log('--- Tour Participants in DB ---');
console.log(participants);
}
main().catch(console.error).finally(() => prisma.$disconnect());
+17
View File
@@ -0,0 +1,17 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tours = await prisma.tour.findMany({
include: {
participants: {
include: {
user: { select: { email: true, name: true } }
}
}
}
});
console.log(JSON.stringify(tours, null, 2));
}
main().finally(() => prisma.$disconnect());
+36
View File
@@ -0,0 +1,36 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Use existing tour ID from seed
console.log('Inserting first manual member...');
try {
const p1 = await prisma.tourParticipant.create({
data: {
tourId,
role: 'MEMBER',
displayName: 'Manual Member 1'
}
});
console.log('Inserted:', p1);
} catch (err) {
console.error('Failed to insert first:', err);
}
console.log('Inserting second manual member...');
try {
const p2 = await prisma.tourParticipant.create({
data: {
tourId,
role: 'MEMBER',
displayName: 'Manual Member 2'
}
});
console.log('Inserted:', p2);
} catch (err) {
console.error('Failed to insert second:', err);
}
}
main().catch(console.error).finally(() => prisma.$disconnect());
+22
View File
@@ -0,0 +1,22 @@
async function test() {
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'photomember@travel.com', password: '123456' })
});
const loginData = await loginRes.json();
if (loginRes.ok) {
const token = loginData.access_token;
const usersRes = await fetch('http://localhost:3001/api/v1/users?q=owner@travel.com', {
headers: { 'Authorization': `Bearer ${token}` }
});
console.log('Users status:', usersRes.status);
const usersData = await usersRes.json();
console.log('Users data:', usersData);
} else {
console.error('Login failed:', loginData);
}
}
test().catch(console.error);
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node", // Đm bo module resolution là 'node'
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false,
"resolveJsonModule": true, // Cho phép import các file .json
"esModuleInterop": true, // Cho phép cú pháp import/export ES Modules vi CommonJS
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

+208
View File
@@ -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;
+12
View File
@@ -12,8 +12,20 @@ interface AddMemberModalProps {
email: string;
};
}>;
joinRequests?: Array<{
id: string;
userId: string;
user?: {
id: string;
name: string;
email: string;
};
status: string;
requestedById: string;
}>;
onRemoveMember?: (userId: string) => Promise<void>;
onMemberAdded?: () => void;
userRole?: string;
}
export declare const AddMemberModal: React.FC<AddMemberModalProps>;
export {};
+44 -11
View File
@@ -1,7 +1,7 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect, useMemo } from 'react';
import { X, Search, UserPlus, Loader2, Shield, Trash2 } from 'lucide-react';
export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onRemoveMember, onMemberAdded }) => {
import { X, UserPlus, Loader2, Shield, Trash2, Clock } from 'lucide-react';
export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
const [query, setQuery] = useState('');
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
@@ -12,8 +12,11 @@ export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onR
const [submitError, setSubmitError] = useState('');
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [confirmTarget, setConfirmTarget] = useState(null);
const [actionLoading, setActionLoading] = useState(null);
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => r.userId)), [joinRequests]);
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
const fetchUsers = async () => {
setLoading(true);
setFetchError('');
@@ -68,6 +71,32 @@ export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onR
setConfirmTarget(null);
}
};
const handleRequestAction = async (reqId, action, userName) => {
if (!onMemberAdded)
return;
setActionLoading(reqId);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const endpoint = action === 'accept'
? `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
: `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || data.error || (action === 'accept' ? 'Không thể chấp nhận' : 'Không thể từ chối'));
}
await onMemberAdded();
}
catch (err) {
alert(err.message || 'Thao tác thất bại');
}
finally {
setActionLoading(null);
}
};
const handleAdd = async () => {
if (!selectedUser)
return;
@@ -75,23 +104,27 @@ export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onR
setSubmitError('');
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
const body = canCreateDirectly
? { userId: selectedUser, role }
: { userId: selectedUser };
const res = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ userId: selectedUser, role }),
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || 'Thêm thành viên thất bại');
throw new Error(data.message || data.error || 'Thao tác thất bại');
}
onMemberAdded?.();
await onMemberAdded?.();
onClose();
}
catch (err) {
setSubmitError(err.message || 'Thêm thành viên thất bại');
setSubmitError(err.message || 'Thao tác thất bại');
}
finally {
setSubmitting(false);
@@ -99,7 +132,7 @@ export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onR
};
if (!isOpen)
return null;
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]", children: [_jsxs("div", { className: "p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50", children: [_jsxs("div", { children: [_jsxs("h2", { className: "text-lg font-bold text-gray-900 flex items-center gap-2", children: [_jsx(UserPlus, { className: "w-5 h-5 text-blue-600" }), " Th\u00EAm th\u00E0nh vi\u00EAn"] }), _jsx("p", { className: "text-xs text-gray-500", children: "Ch\u1ECDn ng\u01B0\u1EDDi d\u00F9ng v\u00E0 ph\u00E2n quy\u1EC1n cho tour n\u00E0y." })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-200 rounded-full transition-colors", children: _jsx(X, { className: "w-5 h-5 text-gray-400" }) })] }), _jsxs("div", { className: "p-5 space-y-4", children: [_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2", children: ["Th\u00E0nh vi\u00EAn c\u1EE7a tour (", participants.length, ")"] }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [participants.map((p) => {
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]", children: [_jsxs("div", { className: "p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50", children: [_jsxs("div", { children: [_jsxs("h2", { className: "text-lg font-bold text-gray-900 flex items-center gap-2", children: [_jsx(UserPlus, { className: "w-5 h-5 text-blue-600" }), " ", canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'] }), _jsx("p", { className: "text-xs text-gray-500", children: canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.' })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-200 rounded-full transition-colors", children: _jsx(X, { className: "w-5 h-5 text-gray-400" }) })] }), _jsxs("div", { className: "p-5 space-y-4", children: [_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2", children: ["Th\u00E0nh vi\u00EAn c\u1EE7a tour (", participants.length, ")"] }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [participants.map((p) => {
const rawToken = localStorage.getItem('token');
let currentUserId = null;
try {
@@ -113,9 +146,9 @@ export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onR
const isOwner = p.role === 'OWNER';
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
return (_jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden", children: p.user?.name?.charAt(0) || '?' }), canRemove && (_jsx("button", { onClick: () => handleRemove(p.userId, p.user?.name || p.userId), className: "absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white", "aria-label": "Remove item", children: _jsx(Trash2, { size: 10 }) }))] }), _jsx("span", { className: "text-[10px] font-semibold text-gray-700 max-w-[72px] truncate", children: p.user?.name || p.userId })] }, p.userId));
}), participants.length === 0 && (_jsx("span", { className: "text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 th\u00E0nh vi\u00EAn n\u00E0o" }))] })] }), _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" }), _jsx("input", { className: "w-full pl-9 pr-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl outline-none text-sm", placeholder: "T\u00ECm theo t\u00EAn ho\u1EB7c email...", value: query, onChange: (e) => setQuery(e.target.value), onBlur: fetchUsers })] }), _jsxs("div", { className: "space-y-1.5", children: [_jsx("label", { className: "text-xs font-bold text-gray-700 ml-1", children: "Ph\u00E2n quy\u1EC1n" }), _jsxs("select", { className: "w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm", value: role, onChange: (e) => setRole(e.target.value), children: [_jsx("option", { value: "OWNER", children: "OWNER" }), _jsx("option", { value: "MANAGER", children: "MANAGER" }), _jsx("option", { value: "MEMBER", children: "MEMBER" }), _jsx("option", { value: "MEMBER_NO_FINANCE", children: "MEMBER_NO_FINANCE" }), _jsx("option", { value: "VIEWER_ONLY", children: "VIEWER_ONLY" })] })] }), _jsxs("div", { className: "space-y-2", children: [fetchError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: fetchError })), submitError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: submitError })), loading ? (_jsx("div", { className: "flex justify-center py-10", children: _jsx(Loader2, { className: "w-8 h-8 animate-spin text-blue-600" }) })) : (_jsxs("div", { className: "space-y-2 max-h-[40vh] overflow-y-auto pr-1", children: [visibleUsers.map((u) => {
}), participants.length === 0 && (_jsx("span", { className: "text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 th\u00E0nh vi\u00EAn n\u00E0o" }))] })] }), joinRequests.length > 0 && (_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3 text-amber-500" }), " \u0110ang ch\u1EDD ph\u00EA duy\u1EC7t (", joinRequests.length, ")"] }), _jsx("div", { className: "flex flex-wrap gap-3", children: joinRequests.map((req) => (_jsxs("div", { className: "flex flex-col items-center gap-1 relative", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold text-sm overflow-hidden", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { className: "absolute -top-1 -right-1 flex", children: [_jsx("button", { type: "button", disabled: actionLoading === req.id, onClick: () => handleRequestAction(req.id, 'accept', req.user?.name || req.userId), 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", children: "+" }), _jsx("button", { type: "button", disabled: actionLoading === req.id, onClick: () => handleRequestAction(req.id, 'reject', req.user?.name || req.userId), 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", children: "x" })] }), _jsx("span", { className: "text-[10px] font-semibold text-gray-700 max-w-[72px] truncate", children: req.user?.name || req.userId }), _jsx("span", { className: "text-[9px] font-bold text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full border border-amber-200", children: "PENDING" })] }, req.id))) })] })), canCreateDirectly && (_jsxs("div", { className: "space-y-1.5", children: [_jsx("label", { className: "text-xs font-bold text-gray-700 ml-1", children: "Ph\u00E2n quy\u1EC1n" }), _jsxs("select", { className: "w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm", value: role, onChange: (e) => setRole(e.target.value), children: [_jsx("option", { value: "OWNER", children: "OWNER" }), _jsx("option", { value: "MANAGER", children: "MANAGER" }), _jsx("option", { value: "MEMBER", children: "MEMBER" }), _jsx("option", { value: "MEMBER_NO_FINANCE", children: "MEMBER_NO_FINANCE" }), _jsx("option", { value: "VIEWER_ONLY", children: "VIEWER_ONLY" })] })] })), _jsxs("div", { className: "space-y-2", children: [fetchError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: fetchError })), submitError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: submitError })), loading ? (_jsx("div", { className: "flex justify-center py-10", children: _jsx(Loader2, { className: "w-8 h-8 animate-spin text-blue-600" }) })) : (_jsxs("div", { className: "space-y-2 max-h-[40vh] overflow-y-auto pr-1", children: [visibleUsers.map((u) => {
const isSelected = selectedUser === u.id;
return (_jsxs("button", { onClick: () => setSelectedUser(u.id), className: `w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'}`, children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold", children: u.name?.charAt(0) || '?' }), _jsxs("div", { className: "flex-1 text-left", children: [_jsx("div", { className: "text-sm font-bold text-gray-900", children: u.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-[11px] text-gray-500", children: u.email }), _jsxs("div", { className: "text-[11px] text-gray-400", children: [u.phone || '', " ", u.address ? `${u.address}` : ''] })] }), _jsxs("div", { className: "flex items-center gap-1 text-[10px] font-bold text-gray-500", children: [u.isAdmin ? (_jsx("span", { className: "px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100", children: "ADMIN" })) : (_jsx("span", { className: "px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100", children: "USER" })), isSelected && _jsx(Shield, { className: "w-3 h-3 text-blue-600" })] })] }, u.id));
}), !loading && visibleUsers.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng t\u00ECm th\u1EA5y ng\u01B0\u1EDDi d\u00F9ng ph\u00F9 h\u1EE3p" }))] }))] })] }), _jsxs("div", { className: "p-5 border-t border-gray-100 flex justify-end gap-2", children: [_jsx("button", { onClick: onClose, className: "px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { disabled: !selectedUser || submitting, onClick: handleAdd, className: "px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all", children: submitting ? 'Đang thêm...' : 'Thêm vào tour' })] })] }), isConfirmOpen && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: () => setIsConfirmOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: "X\u00E1c nh\u1EADn x\u00F3a th\u00E0nh vi\u00EAn" }), _jsxs("p", { className: "mt-2 text-sm text-gray-600", children: ["B\u1EA1n c\u00F3 ch\u1EAFc mu\u1ED1n x\u00F3a ", _jsx("span", { className: "font-semibold text-gray-800", children: confirmTarget?.name }), " kh\u1ECFi tour n\u00E0y?"] }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsConfirmOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("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", children: "X\u00F3a" })] })] })] }))] }));
return (_jsxs("button", { onClick: () => setSelectedUser(u.id), disabled: requestUserIds.has(u.id), className: `w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`, children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold", children: u.name?.charAt(0) || '?' }), _jsxs("div", { className: "flex-1 text-left", children: [_jsx("div", { className: "text-sm font-bold text-gray-900", children: u.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-[11px] text-gray-500", children: u.email }), _jsxs("div", { className: "text-[11px] text-gray-400", children: [u.phone || '', " ", u.address ? `${u.address}` : ''] })] }), _jsxs("div", { className: "flex items-center gap-1 text-[10px] font-bold text-gray-500", children: [u.isAdmin ? (_jsx("span", { className: "px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100", children: "ADMIN" })) : (_jsx("span", { className: "px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100", children: "USER" })), isSelected && _jsx(Shield, { className: "w-3 h-3 text-blue-600" })] })] }, u.id));
}), !loading && visibleUsers.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng t\u00ECm th\u1EA5y ng\u01B0\u1EDDi d\u00F9ng ph\u00F9 h\u1EE3p" }))] }))] })] }), _jsxs("div", { className: "p-5 border-t border-gray-100 flex justify-end gap-2", children: [_jsx("button", { onClick: onClose, className: "px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { disabled: !selectedUser || submitting, onClick: handleAdd, className: "px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all", children: submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời' })] })] }), isConfirmOpen && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: () => setIsConfirmOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: "X\u00E1c nh\u1EADn x\u00F3a th\u00E0nh vi\u00EAn" }), _jsxs("p", { className: "mt-2 text-sm text-gray-600", children: ["B\u1EA1n c\u00F3 ch\u1EAFc mu\u1ED1n x\u00F3a ", _jsx("span", { className: "font-semibold text-gray-800", children: confirmTarget?.name }), " kh\u1ECFi tour n\u00E0y?"] }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsConfirmOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("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", children: "X\u00F3a" })] })] })] }))] }));
};
//# sourceMappingURL=AddMemberModal.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
interface ConfirmModalProps {
isOpen: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
}
export declare const ConfirmModal: React.FC<ConfirmModalProps>;
export {};
+7
View File
@@ -0,0 +1,7 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export const ConfirmModal = ({ isOpen, title = 'Xác nhận', message, confirmText = 'Xác nhận', cancelText = 'Hủy', onConfirm, onCancel, }) => {
if (!isOpen)
return null;
return (_jsxs("div", { className: "fixed inset-0 z-[2200] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: onCancel }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: title }), _jsx("p", { className: "mt-2 text-sm text-gray-600", children: message }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: onCancel, className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: cancelText }), _jsx("button", { onClick: onConfirm, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors", children: confirmText })] })] })] }));
};
//# sourceMappingURL=ConfirmModal.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"ConfirmModal.js","sourceRoot":"","sources":["../ConfirmModal.tsx"],"names":[],"mappings":";AAaA,MAAM,CAAC,MAAM,YAAY,GAAgC,CAAC,EACxD,MAAM,EACN,KAAK,GAAG,UAAU,EAClB,OAAO,EACP,WAAW,GAAG,UAAU,EACxB,UAAU,GAAG,KAAK,EAClB,SAAS,EACT,QAAQ,GACT,EAAE,EAAE;IACH,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IAEzB,OAAO,CACL,eAAK,SAAS,EAAC,6DAA6D,aAC1E,cAAK,SAAS,EAAC,kDAAkD,EAAC,OAAO,EAAE,QAAQ,GAAI,EACvF,eAAK,SAAS,EAAC,8DAA8D,aAC3E,aAAI,SAAS,EAAC,mCAAmC,YAAE,KAAK,GAAM,EAC9D,YAAG,SAAS,EAAC,4BAA4B,YAAE,OAAO,GAAK,EACvD,eAAK,SAAS,EAAC,6BAA6B,aAC1C,iBAAQ,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAC,0FAA0F,YAC5H,UAAU,GACJ,EACT,iBAAQ,OAAO,EAAE,SAAS,EAAE,SAAS,EAAC,iGAAiG,YACpI,WAAW,GACL,IACL,IACF,IACF,CACP,CAAC;AACJ,CAAC,CAAC"}

Some files were not shown because too many files have changed in this diff Show More