133 Commits

Author SHA1 Message Date
3dtours 41c8e67229 fix: photo edit by admin 2026-06-28 11:00:28 +07:00
3dtours 9f60efeb6d fix: remove success notifications and parse map coordinates safely 2026-06-27 22:28:13 +07:00
3dtours 0152e7014d feat: use Capacitor Camera API to select/capture photos to preserve EXIF location on Android app 2026-06-27 22:21:28 +07:00
3dtours 964a72514f feat: use Capacitor Geolocation to prompt and get native location on Android 2026-06-27 22:15:18 +07:00
3dtours 7f426c8e46 feat: replace manual binary EXIF parser with robust exifr library on frontend 2026-06-27 22:04:21 +07:00
3dtours 414fac3e72 feat: add ACCESS_MEDIA_LOCATION permission to AndroidManifest.xml to enable EXIF GPS reading on Android 10+ 2026-06-27 21:59:08 +07:00
3dtours e5606e64e5 feat: enforce 4-level coordinates resolution priority for tour photo uploads in AddPhotoModal.tsx 2026-06-27 21:53:52 +07:00
3dtours 464a4019f1 chore: mount Android app-debug.apk directly as a docker volume in backend container 2026-06-27 21:45:57 +07:00
3dtours a1bf0d2c08 feat: implement imageProcessor.ts and fix fullscreen photo lightbox alignment 2026-06-27 21:42:27 +07:00
3dtours 277f647e40 fix: copy public folder to production docker image stage to serve APK downloads 2026-06-27 21:17:33 +07:00
3dtours f39750af72 feat: implement 4-level priority coordinates resolution on photo upload 2026-06-27 21:08:42 +07:00
3dtours ff4dc9bb48 fix: restrict AppDownloadBanner to mobile browsers only using Capacitor.isNativePlatform() 2026-06-27 20:54:47 +07:00
3dtours 175668da35 feat: restore and integrate AppDownloadBanner under Top-Bar 2026-06-27 20:50:00 +07:00
3dtours 7ab1342690 fix: close login modal automatically on successful login in LandingPage 2026-06-27 20:41:40 +07:00
3dtours 2f4b24c41c fix: add androidClientId matching serverClientId in capacitor.config.ts 2026-06-27 20:36:26 +07:00
3dtours 1417f40dde fix: restore GoogleAuth.initialize on native to prevent startup NullPointerException crash 2026-06-27 20:32:59 +07:00
3dtours eaf79eaf8f fix: call GoogleAuth.initialize only on web to resolve Native OAuth error 2026-06-27 20:29:01 +07:00
3dtours 3fa80b69bf fix: android dropdown menu button 2026-06-27 20:27:44 +07:00
admin a7e569d9b4 merge: gộp thành công phần code sửa trước từ stash 2026-06-27 19:42:45 +07:00
admin 3bc7c8a160 fix: menu on android 2026-06-27 19:38:05 +07:00
admin 9fae6afaac fix: phần code sửa sau trên máy B 2026-06-27 17:52:42 +07:00
admin 614054f35d fix: phần code build 2026-06-27 17:51:19 +07:00
admin 796487ef76 fix: phần code build android 2026-06-27 17:46:23 +07:00
3dtours 813b41933b fix: android file update 2026-06-27 17:05:27 +07:00
3dtours 59153cffc2 fix: android pre-update 2026-06-27 16:54:47 +07:00
3dtours 4ee46371fa fix: gộp các nút thành viên và bỏ MemberDashboard 2026-06-27 16:53:19 +07:00
3dtours 400a3d098d fix: gộp các nút của thành viên và guest vào một 2026-06-27 15:37:00 +07:00
3dtours 5f49070a98 merge: giải quyết xung đột env và dashboard giữa 2 máy 2026-06-27 12:09:01 +07:00
3dtours accda67b22 fix: cập nhật giao diện và các trang dashboard từ máy B 2026-06-27 11:44:28 +07:00
admin fee2aed2e6 fix: sửa lỗi notification và giao diện chat trên android 2026-06-27 11:33:07 +07:00
admin ee42bfcefa fix: build ứng dụng trên android và có thể đăng nhập bằng Google Oauth 2026-06-27 10:39:22 +07:00
3dtours b5b6082d26 fix: update env 2026-06-26 21:00:50 +07:00
3dtours d8bbb22dbd feat: tạo ứng dụng android 2026-06-26 20:33:14 +07:00
3dtours 4da00b222f fix: Thay đổi giao diện hiển thị trên điện thoại và đổi đường dẫn lưu ảnh upload ở docker-compose.prod.yml 2026-06-26 20:03:30 +07:00
3dtours cfb97d976f fix: cách các pages hiển thị trên browser điện thoại 2026-06-26 10:21:01 +07:00
3dtours 6d90a47c24 fix: cách các modals hiển thị trên browser điện thoại 2026-06-26 09:13:24 +07:00
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
230 changed files with 38476 additions and 1942 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/
+14
View File
@@ -0,0 +1,14 @@
DATABASE_URL="postgresql://postgres:password@localhost:5432/travel_db?schema=public"
PORT=3001
REDIS_URL=redis://localhost:6379
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=yotripadmin@gmail.com
SMTP_PASS="ahhh suif kfcp waie"
GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
FRONTEND_URL=https://yotrip.labz.io.vn
ADMIN_SECRET_KEY=Yotrip@Bitabub1
# Development: Backend URL for vite dev server proxy (only used with npm run dev)
# For production: Nginx handles proxying, this is not used
VITE_BACKEND_URL=http://localhost:3001
+2 -1
View File
@@ -1,2 +1,3 @@
.env
node_modules
server/dist
dist
+2 -1
View File
@@ -1,3 +1,4 @@
{
"continue.enableConsole": true
"continue.enableConsole": true,
"remote.autoForwardPortsFallback": 0
}
+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
+83
View File
@@ -0,0 +1,83 @@
# To AI Agent: Global Mobile Viewport Optimization Plan for `frontend/src/pages`
## 1. Context & Architectural Objective
We are launching a comprehensive mobile responsive refactoring across all core application pages inside `frontend/src/pages/`. Currently, several layouts suffer from desktop-first design assumptions, leading to sideways horizontal scrolling, squished sidebars, text wrapping collisions, and clipped viewports on mobile browsers.
**Objective:** Inspect and refactor all page-level layout components to guarantee an impeccable, fluid mobile UX (screen widths under 640px) while preserving the current widescreen layout using Tailwind CSS responsive breakpoints (`sm:`, `md:`, `lg:`).
---
## 2. Core Mobile-Responsive Design Rules for Pages
When auditing and refactoring page containers, strictly enforce these implementation guardrails:
1. **Fluid Heights over Sticky Viewports:** Avoid hardcoding page heights to `h-screen`. On mobile browsers, the address bar dynamically expands and collapses, causing layout jumps. Use **`h-auto`** or the new dynamic viewport utilities **`h-dvh`** / **`min-h-dvh`** instead.
2. **Horizontal Overflow Elimination:** Ensure the root wrapper of every page enforces `w-full overflow-x-hidden`. Any element causing a horizontal scrollbar must be converted to a flex-wrap, horizontal scroll grid, or dynamic stack.
3. **Flex/Grid Stacking:** Multi-column dashboard layouts must stack vertically on mobile and separate into side-by-side structures on desktop:
- Use `flex flex-col md:flex-row`
- Use `grid grid-cols-1 md:grid-cols-3`
4. **Touch Target & Spacing Downscaling:** Mobile views require higher breathing margins but smaller typography. Reduce text sizes (`text-base``text-xs/sm`) and scale down paddings (`p-6``p-3/4`) on mobile screens.
---
## 3. Targeted Page-by-Page Refactoring Guide
### 3.1. `MemberDashboard.tsx` (Trang chính / Danh sách Tour)
- **The Issue:** The grid grid-cols-2 or grid-cols-3 arrangement squishes Tour Card components on small viewports.
- **Refactor Spec:** - Change main wrapper grid to `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4`.
- Force individual Tour Cards to occupy 100% width on mobile, stacking all actions ("Trò chuyện", "Chi tiết hành trình") into standard full-width rows or equal flex pairs (`flex-1`).
### 3.2. `ItineraryTimeline.tsx` (Trang quản lý Lộ trình Chi tiết)
- **The Issue:** The sub-navigation tabs ribbon ("Lộ trình, Chi phí, Ảnh...") gets compressed, causing word overlapping. Timeline lines and node circles clip when left padding is too wide.
- **Refactor Spec:**
- Convert the sub-navigation menu container into a smooth horizontally scrollable ribbon on mobile:
```jsx
className="flex items-center gap-2 overflow-x-auto whitespace-nowrap scrollbar-none pb-2 md:overflow-x-visible md:whitespace-normal"
```
- Reduce the absolute left offset tracking of the timeline vertical axis indicator from `left-[32px]` down to a safe margin fitting tight spaces.
### 3.3. `PhotoGallery.tsx` / `GalleryPage.tsx` (Thư viện ảnh Tour)
- **The Issue:** Widescreen image matrices cause layout bleeding or weird masonry columns.
- **Refactor Spec:**
- Force image grid wrappers to adopt `grid-cols-2` or `grid-cols-3` on mobile browsers instead of desktop 4-5 structures.
- Ensure the modal lightboxes or floating overlays use full-width settings (`w-screen h-screen`) with zero perimeter radius limits.
---
## 4. Code Refactoring Reference Standard
### Layout Component Conversion Pattern:
Apply this fluid adaptation standard on your root page return templates:
```jsx
{/* ❌ BEFORE: RIGID DESKTOP-FIRST PAGE WRAPPER */}
<div className="w-screen h-screen bg-slate-950 flex p-6 gap-6">
<aside className="w-64 bg-slate-900">Sidebar</aside>
<main className="flex-1 overflow-y-auto">Main Dashboard Content</main>
</div>
{/* ✅ AFTER: MOBILE-FIRST FULLY RESPONSIVE LAYOUT SHEET */}
<div className="w-full min-h-dvh bg-slate-950 flex flex-col md:flex-row p-3 sm:p-6 gap-4 sm:gap-6 overflow-x-hidden">
{/* Sticky Navigation or Drawer Menu on Mobile, Fixed Sidebar on Desktop */}
<aside className="w-full md:w-64 shrink-0 bg-slate-900 rounded-xl p-4 md:sticky md:top-6 md:h-[calc(100vh-3rem)]">
Sidebar/Menu Content
</aside>
{/* Main Scrollable Core Content Space */}
<main className="w-full flex-1 overflow-y-visible md:overflow-y-auto">
<div className="space-y-4 max-w-7xl mx-auto">
{/* Grid elements stack on mobile (1 col) and expand on desktop */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Card components populate here */}
</div>
</div>
</main>
</div>
## 5. Automated Verification Checklist for AI Agent
[ ] Zero Pixel Width Hardcodes: Scan all code blocks in frontend/src/pages/. Ensure no outer boundaries utilize structural fixed layouts like w-[1200px] or w-[800px] without a breakpoint utility prefix (e.g., lg:w-[1200px]).
[ ] Viewport Axis Lock: Simulate viewport checks at 360px, 390px, and 412px widths. Verify that horizontal browser layout shifting is fully neutralized (window.scrollX === 0).
[ ] Dynamic Viewport Heights Verification: Confirm that full-page dashboards replace static h-screen classes with h-auto or dynamic min-h-dvh settings to avoid layout bugs when the mobile address bar shifts.
+110
View File
@@ -0,0 +1,110 @@
# To AI Agent: Restore Smart Android App Download Banner Below Top-Bar Header
## 1. Context & Feature Objective
Previously, the codebase had a promotional banner encouraging mobile users to download the native Android `.apk` file. However, during the recent overhaul of the Top-Bar header and Member Dropdown Menu configurations, this banner component was accidentally unmounted or hidden.
**Objective:** Restore and refactor this promotional frame (`AppDownloadBanner.tsx`). It must **ONLY** appear when a user accesses the web application via a **Mobile Android Browser** (Chrome, Samsung Internet, Opera Mobile, etc.). It must be positioned dynamically as a small, clean horizontal frame pinned directly underneath the main Top-Bar header layout, without conflicting with the new absolute Profile Dropdown Menu.
---
## 2. Visual & Behavioral Layout Specifications
- **Placement Context:** Directly underneath the Top-Bar layer, shifting the core page content (Map Canvas or Landing Hero) down proportionally (`relative` or `sticky` stack). It must not overlay or block map navigation tools.
- **Conditional Trigger Logic:** The banner must evaluate `navigator.userAgent`. If the user agent includes `'android'` **AND** the user is running inside a standard web browser (not inside the compiled wrapper app itself), display the banner.
- **Dismissible Interaction:** Include a small close button (`X`). Clicking it should temporarily store a flag in `sessionStorage` or `localStorage` to prevent the banner from bugging the user repeatedly during their session.
---
## 3. Technical Implementation Blueprint
### Step 1: Create the Responsive Banner Component (`AppDownloadBanner.tsx`)
Create or re-engineer the banner layout within `frontend/src/components/layout/AppDownloadBanner.tsx`:
```typescript
import React, { useState, useEffect } from 'react';
import { X, Download } from 'lucide-react';
export const AppDownloadBanner: React.FC = () => {
const [isVisible, setIsVisible] = useState(false);
const APK_DOWNLOAD_URL = `${import.meta.env.VITE_BACKEND_URL || window.location.origin}/downloads/yotrip-latest.apk`;
useEffect(() => {
const userAgent = navigator.userAgent.toLowerCase();
const isAndroidBrowser = userAgent.includes('android') && !window.location.origin.includes('capacitor://') && !window.location.origin.includes('localhost:80');
const isBannerDismissed = localStorage.getItem('yotrip_apk_banner_dismissed') === 'true';
// ✅ Target condition match: User is on Android mobile browser and hasn't closed it yet
if (isAndroidBrowser && !isBannerDismissed) {
setIsVisible(true);
}
}, []);
const handleDismiss = () => {
localStorage.setItem('yotrip_apk_banner_dismissed', 'true');
setIsVisible(false);
};
if (!isVisible) return null;
return (
<div className="w-full bg-gradient-to-r from-blue-900 to-indigo-950 border-b border-blue-800 px-4 py-2 flex items-center justify-between text-white text-[11px] font-medium z-40 relative animate-fade-in shrink-0">
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<div className="p-1 bg-blue-500/20 rounded-lg text-blue-400 shrink-0">
<Download className="w-3.5 h-3.5" />
</div>
<p className="truncate text-slate-200">
Trải nghiệm mượt mà hơn với ứng dụng <span className="text-white font-bold">YoTrip cho Android</span>!
</p>
</div>
<div className="flex items-center gap-3 ml-2 shrink-0">
{/* Direct Download Trigger Target Link */}
<a
href={APK_DOWNLOAD_URL}
download="yotrip.apk"
className="bg-blue-600 hover:bg-blue-500 px-2.5 py-1 rounded font-bold text-white transition-colors shadow-sm active:scale-95"
>
Tải APK
</a>
{/* Close Button Trigger */}
<button
onClick={handleDismiss}
className="p-1 hover:bg-slate-800 rounded text-slate-400 hover:text-slate-200 transition-colors"
title="Đóng thông báo"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
};
### Step 2: Integrate into Layout Hierarchies (LandingPage.tsx & ExplorerMap.tsx)
Mount the verified download banner right below your primary header or top-bar row item array context:
{/* ❌ BEFORE STRUCTURAL INTEGRATION */}
<div className="w-screen h-screen flex flex-col">
<TopBarHeader />
<MapCanvasWorkspace />
</div>
{/* ✅ AFTER STRUCTURAL INTEGRATION: Stacked relative context */}
<div className="w-screen h-screen flex flex-col overflow-hidden">
{/* 1. Main Application Header */}
<TopBarHeader />
{/* 2. THE RESTORED ANDROID APP PROMOTIONAL BANNER */}
<AppDownloadBanner />
{/* 3. Primary Workspace Area - Shifts down cleanly when banner initializes */}
<div className="flex-1 relative min-h-0 w-full">
<MapCanvasWorkspace />
</div>
</div>
## 4. Verification & Quality Acceptance Criteria for AI Agent
[ ] Targeted UserAgent Isolation: Emulate a desktop display width (iPhone or Desktop layouts). Verify the banner remains completely hidden. Toggle the network responsive preview device model to Android (e.g., Pixel 7) and refresh. Confirm the download bar pops up seamlessly.
[ ] Absolute Dropdown Overlay Preservation: Expand the Member Avatar menu row dropdown list while the banner is visible. Confirm that the dropdown box displays layered ON TOP of the banner context, without layout shifting or text clipping.
[ ] State Dismissal Memory: Click the X button on the banner, then refresh the browser session. Confirm the banner remains hidden and respects the localStorage condition toggle.
+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.
+129
View File
@@ -0,0 +1,129 @@
# To AI Agent: Deprecate MemberDashboard Page and Re-architect All User Features into Dedicated Modals on LandingPage
## 1. Architectural Strategy & Goal
We are completely removing the standalone `MemberDashboard` route/page. When a user logs in successfully, they must **remain directly on the `LandingPage`** (or `ExplorerMap`), with their authentication state shifting cleanly to display the member avatar dropdown menu in the top-bar header.
Every core feature previously hosted on the dashboard page must now be converted into a high-performance, responsive **Modal Overlay Component**. Clicking an item in the avatar dropdown menu will toggle the visibility state of its respective modal over the current viewport, ensuring zero page-redirection disruption.
---
## 2. Route Deletion & Auth Redirection Clean-up
### 1. **Route Removal:** Open your router configuration (`App.tsx` or `routes.tsx`) and permanently delete the `<Route path="/dashboard" ... />` node.
### 2. **Auth Hook Modification:** Inside the login handler lifecycle (e.g., `LoginModal.tsx` or `AuthContext.tsx`), replace `Maps('/dashboard')` with a simple state closer that preserves the current page instance:
```typescript
// ❌ OLD: navigate('/dashboard');
// ✅ NEW: Keep user on context page, close login overlay, update profile states
setIsLoginModalOpen(false);
## 3. Technical Specifications for Each Target Modal
Implement the following modal architectures inside frontend/src/components/modals/:
### 3.1. Modal Chat Trực Tiếp (LiveChatModal.tsx)
Layout Architecture: A wide responsive viewport split into two main functional vertical columns (flex flex-col md:flex-row h-[80vh]).
Left Column (w-full md:w-80 border-r border-slate-800): Interactive scrollable contact strip displaying the User's Friend List with online status indicators and latest message snippets.
Right Column (flex-1 flex flex-col bg-slate-950): Active conversation window.
Bottom Input Tray Wrapper: A rich-text typing container bar fixed at the baseline containing:
Text input area field.
Attachment Trigger buttons: Icon for Image uploads (accept="image/*") and an Icon for transmitting spatial GPS Coordinates/Locations directly into the text stream.
### 3.2. Modal Hành Trình Của Tôi (MyToursModal.tsx)
Layout Architecture: A dynamic card explorer view featuring structural chronological timeline tabs at the top:
Tabs Grid: Đang thực hiện (Ongoing), Sắp khởi hành (Upcoming), and Đã hoàn thành (Past).
Core Content Panel: Clicking a tab renders a fluid inner grid loop of compact trip tiles. Each tile houses a banner photo, progress indicators, and quick action links to open the standalone TourNavigationPage or route maps directly.
### 3.3. Modal Thư Viện Ảnh (PhotoGalleryModal.tsx)
Layout Architecture: A dedicated media repository browser displaying all images uploaded by the member.
Filtering Header Matrix: Dual filter select boxes pinned at the top:
Filter 1: Filter by Itinerary/Trip (Theo hành trình).
Filter 2: Filter by Media Hashtags (Theo tags).
Core Workspace: A masonry-style gallery layout grid with hover micro-interactions enabling the user to view full resolution views, edit asset tagging descriptions, or delete pictures directly.
### 3.4. Modal Danh Sách Bạn Bè (FriendsManagerModal.tsx)
Layout Architecture: A centralized social dashboard layout built to handle user connections.
Functional Subsections:
Search engine bar to lookup new profiles via display name or telephone metrics.
Tab views separating Danh sách bạn bè (Active Friends) and Lời mời kết bạn (Pending Requests).
Row items equipped with direct context action triggers: Hủy kết bạn (Unfriend), Chấp nhận (Accept), or Nhắn tin (Quick Message - which bridges states to auto-toggle the Chat Modal).
## 4. Top-Bar Profile Dropdown Structure Integration
Refactor the items list container within MapProfileDropdown.tsx to match the localized modal toggles state logic:
TypeScript
import React, { useState } from 'react';
import { Compass, Map, Image, Settings, ShieldAlert, Users, LogOut } from 'lucide-react';
export const HeaderMemberDropdown = ({ openModal }) => {
return (
<div className="absolute right-0 top-14 w-64 bg-slate-900 border border-slate-800 rounded-xl p-1.5 shadow-2xl flex flex-col space-y-0.5 text-xs text-slate-200 z-[999999]">
{/* 1. Nút "Tạo tour" */}
<button onClick={() => openModal('create_tour')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Compass className="w-4 h-4 text-amber-400" />
<span className="font-medium">Tạo tour</span>
</button>
{/* 2. Nút "Hành trình của tôi" */}
<button onClick={() => openModal('my_tours')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Map className="w-4 h-4 text-blue-400" />
<span className="font-medium">Hành trình của tôi</span>
</button>
{/* 3. Nút "Thư viện ảnh" */}
<button onClick={() => openModal('gallery')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Image className="w-4 h-4 text-emerald-400" />
<span className="font-medium">Thư viện ảnh</span>
</button>
{/* 4. Nút "Cài đặt" */}
<button onClick={() => openModal('settings')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Settings className="w-4 h-4 text-slate-400" />
<span className="font-medium">Cài đặt</span>
</button>
{/* 5. Nút "Báo cáo vi phạm" */}
<button onClick={() => openModal('reports')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<ShieldAlert className="w-4 h-4 text-rose-400" />
<span className="font-medium">Báo cáo vi phạm</span>
</button>
<div className="h-[1px] bg-slate-800/80 my-1 mx-2" />
{/* 6. Nút "Danh sách bạn bè" */}
<button onClick={() => openModal('friends')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Users className="w-4 h-4 text-indigo-400" />
<span className="font-medium">Danh sách bạn bè</span>
</button>
{/* 7. Nút "Đăng xuất" */}
<button onClick={() => openModal('logout')} className="flex items-center gap-3 w-full px-4 py-2.5 text-rose-400 hover:bg-slate-800 rounded-lg text-left font-bold transition-colors">
<LogOut className="w-4 h-4 text-rose-500" />
<span>Đăng xuất</span>
</button>
</div>
);
};
## 5. Automation Checklists for Quality Verification
[ ] Redirection Invalidation Check: Perform a successful user login sweep. Verify the active URL hash parameter remains exactly / or /map, completely dropping dashboard transitions.
[ ] Chat Modal Layout Sizing: Trigger the Live Chat button. Ensure the workspace scales to an explicit split-view pane framework (Left: Contacts / Right: Dialog Thread) with working attachment trays.
[ ] Gallery Filter Interception: Confirm that filtering options inside the Photo modal dynamically re-index asset cards based on itinerary tags or custom upload parameters.
[ ] Global Z-Index Verification: All new modals must enforce a strict z-[999999] utility layer rule to pop up cleanly above the underlying map viewport layer without clip cutting.
+181
View File
@@ -0,0 +1,181 @@
# To AI Agent: Fix EXIF GPS Extraction, Implement Client-Side 2K Image Resizing, and Fix Android Fullscreen Lightbox Alignment
## 1. Context & Feature Objectives
We are addressing three crucial image-handling and layout bugs on the mobile/Android web wrapper:
1. **Fix (Missing EXIF Location):** When users upload photos, the system fails to extract the geographic coordinates (Latitude/Longitude) embedded within the image metadata. We need to parse EXIF data completely on the client side before submission.
2. **Feat (Native Save & 2K Downscale):** Whether the user shoots a new photo via the Camera or picks one from the Gallery, the original image must remain safely stored in the phone's native album (handled by native webview permissions). Before uploading the file to our Debian server, the frontend must dynamically resize/downscale the image to a maximum resolution of **2K (2048px on its longest edge)** to optimize network bandwidth and server storage.
3. **Bug (Fullscreen Viewer Displacement):** When clicking an image inside the gallery/photo manager view to preview it in fullscreen mode on an Android device, the image incorrectly aligns to the absolute bottom edge of the viewport instead of centering beautifully.
---
## 2. Technical Execution Strategy
### 2.1. Client-Side EXIF Processing & Metadata Preservation
Standard browser file inputs often strip EXIF headers during dynamic manipulation or fail to parse them natively. We will introduce `exif-js` or use a standard binary array buffer scanner to extract the `GPSLatitude` and `GPSLongitude` headers right before resizing occurs, attaching them to the final multipart upload payload.
### 2.2. Downscaling to 2K via HTML5 Canvas
To achieve hardware-accelerated image scaling on mobile devices without losing core image visibility, the source image will be rendered onto an offscreen `<canvas>` container configured to enforce a `max-dimension` of `2048px`, maintaining the original aspect ratio.
### 2.3. Flexbox/Absolute Centering Fix for Android Lightbox
The bottom-displacement bug is tied to incorrect layout bounds calculations on mobile screens when toolbars or navigation rows shift view heights. We will refactor the Lightbox container modal to use rigid viewport configurations (`fixed inset-0`) along with standard vertical centering mechanics.
---
## 3. Code Refactoring Blueprint
### Step 1: Implement Image Metadata Picker & 2K Resizer Logic (`imageProcessor.ts`)
Create a utility service at `frontend/src/utils/imageProcessor.ts` to handle metadata extraction and canvas downscaling sequentially:
```typescript
import EXIF from 'exif-js';
interface ProcessedImageResult {
file: Blob;
latitude: number | null;
longitude: number | null;
}
// Helper to convert EXIF rational coordinates to standard decimal degrees
const convertDMSToDD = (dms: number[], ref: string): number => {
if (!dms || dms.length < 3) return 0;
const degrees = dms[0] + dms[1] / 60 + dms[2] / 3600;
return ref === 'S' || ref === 'W' ? -degrees : degrees;
};
export const processAndResizeImage = (file: File): Promise<ProcessedImageResult> => {
return new Promise((resolve) => {
let latitude: number | null = null;
let longitude: number | null = null;
// 1. EXTRACT EXIF METADATA BEFORE CANVAS CLEARING
EXIF.getData(file as any, function (this: any) {
const allTags = EXIF.getAllTags(this);
if (allTags.GPSLatitude && allTags.GPSLatitudeRef) {
latitude = convertDMSToDD(allTags.GPSLatitude, allTags.GPSLatitudeRef);
}
if (allTags.GPSLongitude && allTags.GPSLongitudeRef) {
longitude = convertDMSToDD(allTags.GPSLongitude, allTags.GPSLongitudeRef);
}
console.log(`📸 Extracted EXIF Metadata - Lat: ${latitude}, Lng: ${longitude}`);
// Proceed directly to resizing stage
proceedToResize();
});
function proceedToResize() {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
const MAX_SIZE = 2048; // Enforce rigid 2K maximum boundary limit
// Calculate ideal bounding proportions
if (width > height) {
if (width > MAX_SIZE) {
height = Math.round((height * MAX_SIZE) / width);
width = MAX_SIZE;
}
} else {
if (height > MAX_SIZE) {
width = Math.round((width * MAX_SIZE) / height);
height = MAX_SIZE;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return resolve({ file, latitude, longitude });
// Render image onto downscaled dimensions canvas bounding box
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (blob) {
resolve({
file: blob,
latitude,
longitude
});
} else {
resolve({ file, latitude, longitude });
}
}, 'image/jpeg', 0.88); // 88% quality compression sweet-spot
};
};
}
});
};
### Step 2: Update Image Upload Handler Layer
Integrate the processor wrapper inside your central upload function (e.g., ImageUploader.tsx or your form submission handler):
import { processAndResizeImage } from '../../utils/imageProcessor';
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const targetFile = event.target.files?.[0];
if (!targetFile) return;
try {
// 1. Process image: Reads EXIF data and forces 2K resizing on device memory
const { file, latitude, longitude } = await processAndResizeImage(targetFile);
const formData = new FormData();
formData.append('image', file, 'yotrip_upload.jpg');
// 2. Append coordinates safely to standard server fields
if (latitude !== null && longitude !== null) {
formData.append('latitude', latitude.toString());
formData.append('longitude', longitude.toString());
}
// 3. Post to API endpoint
const response = await api.post('/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log("✅ Media successfully synchronized with server backend:", response.data);
} catch (error) {
console.error("Failed to safely prepare media stream:", error);
}
};
### Step 3: Fix Fullscreen Image Alignment Layout (ImageLightbox.tsx)
Locate your photo viewer overlay or modal drawer component. Overhaul the tailwind utilities to guarantee true vertical and horizontal centering layout balance on Android devices:
{/* ❌ BEFORE: Faulty container pinning images to device bottom edges */}
<div className="fixed inset-0 bg-black flex items-end justify-center">
{/* ✅ AFTER: True viewport overlay centering bounding context */}
<div className="fixed inset-0 bg-black/95 backdrop-blur-sm flex flex-col items-center justify-center z-[999999] overflow-hidden animate-fade-in">
{/* Close Button Top Tracker Bar Container */}
<div className="absolute top-4 right-4 z-50">
<button className="p-2.5 bg-slate-900/60 rounded-full text-white">✕</button>
</div>
{/* Image wrapper frame context forcing clean alignment metrics */}
<div className="w-full h-full flex items-center justify-center p-4">
<img
src={currentImageUrl}
alt="YoTrip Preview"
className="max-w-full max-h-full object-contain select-none pointer-events-auto"
style={{
/* Prevent Android webviews from accidental shifting behaviors */
transform: 'translate3d(0, 0, 0)'
}}
/>
</div>
</div>
## 4. Automated Verification Checklist for AI Agent
[ ] EXIF Validation Verification: Test uploading a photo embedded with active geolocation values. Inspect the API outgoing transmission payload in the network panel; latitude and longitude fields must contain accurate decimal metrics instead of blank string indicators.
[ ] Longest-Edge Constraint Check: Upload a ultra-high resolution image (e.g., 4000px wide). Verify that the processed file size shrinks significantly, and confirm through terminal logging that the generated canvas asset limits width/height strictly to 2048px.
[ ] Android Centering Success: Activate the image preview mode inside the Android Simulator. The image layout must align mathematically dead-center vertically, leaving symmetric padding bars on both the top header and bottom system navigation boundaries.
+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
+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.
+518
View File
@@ -0,0 +1,518 @@
# Plan: Build ứng dụng Android 11+ từ Codebase hiện tại
## Tổng quan
Codebase hiện tại là một **web app React + Vite** (frontend) và **NestJS** (backend) theo mô hình monorepo. Chiến lược được đề xuất là dùng **Capacitor.js** để đóng gói web app thành native Android APK/AAB mà **không cần viết lại code**, đồng thời bổ sung các tính năng native (camera, GPS, notifications...) qua Capacitor plugins.
---
## So sánh các lựa chọn
| Phương pháp | Ưu điểm | Nhược điểm | Phù hợp? |
|---|---|---|---|
| **Capacitor.js** | Tái sử dụng 100% React code, hỗ trợ Vite, ít học thêm | Cần build native mỗi lần release | ✅ **Phù hợp nhất** |
| React Native | Performance tốt hơn, native feel | Phải viết lại toàn bộ UI/components | ❌ Tốn quá nhiều công |
| PWA (Add to Home Screen) | Không cần build APK | Bị giới hạn API trình duyệt, không lên Google Play | ❌ Không phù hợp |
| Cordova/PhoneGap | Tương tự Capacitor | Cũ hơn, ít được duy trì | ❌ Không nên dùng |
**→ Quyết định: Sử dụng [Capacitor.js](https://capacitorjs.com/)** (do Ionic team phát triển), hỗ trợ Android API 30+ (Android 11).
---
## Kiến trúc triển khai
```
┌─────────────────────────────────────────────────────┐
│ Android APK / AAB │
│ ┌─────────────────────────────────────────────┐ │
│ │ Capacitor WebView │ │
│ │ (Chứa toàn bộ frontend React/Vite) │ │
│ └──────────────────┬──────────────────────────┘ │
│ │ HTTPS API calls │
└─────────────────────┼───────────────────────────────┘
┌────────────────────────┐
│ NestJS Backend │
│ (Deployed server / │
│ localhost dev) │
└────────────────────────┘
```
---
## Thông tin đã xác nhận
| Hạng mục | Quyết định |
|---|---|
| **Tên app** | YoTrip |
| **App ID** | `com.yotrip.app` |
| **Phát hành** | Google Play Store |
| **Camera** | Native (chụp ảnh trực tiếp từ app) |
| **Backend** | Docker trên Debian Homelab, proxy qua Nginx |
| **Domain** | `yotrip.labz.io.vn` |
| **DNS động** | Cloudflare DDNS |
---
## Điều kiện tiên quyết
> [!IMPORTANT]
> Trước khi thực hiện, cần chuẩn bị:
> 1. **Java JDK 17+** — Android build tool yêu cầu
> 2. **Android Studio** — để build APK, quản lý Android SDK và tạo/chạy Emulator
> 3. **Android SDK** với API Level 30+ (Android 11 / API 30)
> 4. **Android Virtual Device (AVD)** — tạo trong Android Studio AVD Manager
> 5. **Domain + HTTPS cho Homelab** — bắt buộc cho Google Play (xem Phần Bên dưới)
> [!WARNING]
> **Google Play bắt buộc HTTPS** — Android 9+ chặn HTTP rõ ràng mặc định. Homelab phải có SSL certificate hợp lệ (Let's Encrypt qua Nginx) và domain name trỏ vào homelab server.
> [!NOTE]
> Trên emulator, `localhost` của **emulator** khác với `localhost` của máy tính. Phải dùng địa chỉ đặc biệt `10.0.2.2` thay thế khi test emulator (xem Phase 5b).
---
## Proposed Changes
### Phase 1a: Cấu hình Backend Homelab (Docker + Nginx)
---
> [!IMPORTANT]
> Đây là điều kiện tiên quyết của toàn bộ kế hoạch. App không thể gửi lên Google Play nếu API chưa có HTTPS.
Homelab của bạn chạy Docker + Nginx là **đủ điều kiện kết nối**, nhưng cần đảm bảo:
#### Checklist Homelab/Nginx
| Yêu cầu | Mô tả |
|---|---|
| **Domain name** | `yotrip.labz.io.vn` — đã xác nhận |
| **Port forwarding** | Router cài đặt forward port 80 và 443 vào Nginx server |
| **SSL Certificate** | Let's Encrypt (miễn phí) qua Certbot: `certbot --nginx -d yotrip.labz.io.vn` |
| **Nginx reverse proxy** | Forward HTTPS → NestJS container port 3001 |
| **Docker Compose** | Backend container luôn restart khi Debian reboot |
| **CORS** | Backend phải cho phép origin từ app Capacitor (có thể mở rộng `*` ban đầu) |
| **IP động** | Dùng **Cloudflare DDNS** để giữ domain `yotrip.labz.io.vn` luôn trỏ đúng IP |
#### Mẫu cấu hình Nginx reverse proxy
```nginx
# /etc/nginx/sites-available/yotrip-api
server {
listen 443 ssl;
server_name yotrip.labz.io.vn;
ssl_certificate /etc/letsencrypt/live/yotrip.labz.io.vn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yotrip.labz.io.vn/privkey.pem;
location / {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
# Cần thiết cho WebSocket (Socket.IO)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
# Redirect HTTP sang HTTPS
server {
listen 80;
server_name yotrip.labz.io.vn;
return 301 https://$host$request_uri;
}
```
#### File cấu hình môi trường sẽ dùng
```env
# frontend/.env.production
VITE_API_BASE_URL=https://yotrip.labz.io.vn
# frontend/.env.emulator (test local)
VITE_API_BASE_URL=http://10.0.2.2:3001
```
---
### Phase 1b: Tập trung API URL trong Frontend
#### [MODIFY] [vite.config.ts](file:///home/locpham/travelplanning/frontend/vite.config.ts)
- Dev proxy hiện tại trỏ về `http://localhost:3001` — chỉ hoạt động khi chạy trên browser máy tính.
- Tạo biến môi trường `VITE_API_BASE_URL` để frontend biết trỏ đến đâu.
#### [NEW] `.env.production` trong `frontend/`
```env
VITE_API_BASE_URL=https://your-backend-server.com
```
#### [NEW] `.env.development` trong `frontend/`
```env
# Dùng ngrok hoặc IP máy tính cho mobile dev
VITE_API_BASE_URL=http://192.168.x.x:3001
```
#### [MODIFY] Toàn bộ các file gọi `/api/v1/...`
- Thay `fetch('/api/v1/...')` bằng `fetch(\`${import.meta.env.VITE_API_BASE_URL}/api/v1/...\`)`
- **Ưu tiên**: Tạo một file `src/lib/api.ts` (helper) để tập trung URL, tránh sửa từng file.
```typescript
// src/lib/api.ts
export const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
export const apiFetch = (path: string, options?: RequestInit) =>
fetch(`${API_BASE}${path}`, options);
```
---
### Phase 2: Tích hợp Capacitor
---
#### Cài đặt Capacitor vào frontend
```bash
# Trong thư mục frontend/
npm install @capacitor/core @capacitor/cli
npx cap init "YoTrip" "com.yotrip.app" --web-dir dist
npm install @capacitor/android
npx cap add android
```
#### [NEW] `frontend/capacitor.config.ts`
```typescript
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.yotrip.app', // ✅ App ID chính thức
appName: 'YoTrip', // ✅ Tên app
webDir: 'dist',
server: {
// Production: không cần cấu hình, dùng VITE_API_BASE_URL trong build
// Dev/Emulator: uncomment dòng dưới
// url: 'http://10.0.2.2:3002',
// cleartext: true,
},
android: {
minSdkVersion: 30, // Android 11 (API 30)
targetSdkVersion: 34, // Android 14
buildOptions: {
keystorePath: 'release-key.keystore',
keystoreAlias: 'yotrip',
}
},
plugins: {
SplashScreen: {
launchAutoHide: false,
backgroundColor: '#0f172a',
androidSplashResourceName: 'splash',
},
// Camera plugin config (bắt buộc vì app cần chụp ảnh)
Camera: {
presentationStyle: 'fullscreen',
}
}
};
export default config;
```
---
### Phase 3: Android Project Setup
---
#### Build flow
```bash
# Bước 1: Build React app thành static files
npm run build -w frontend
# Bước 2: Copy static files vào Android project
npx cap copy android
# Bước 3: Sync plugins và dependencies
npx cap sync android
# Bước 4: Mở Android Studio để build APK/AAB
npx cap open android
```
#### [MODIFY] `android/app/src/main/AndroidManifest.xml` (tự sinh bởi Capacitor)
Thêm các permissions cần thiết:
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Android 11+ scoped storage -->
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
```
---
### Phase 4: Native Plugins (**Bắt buộc**)
| Plugin | Mức độ | Mục đích | Package |
|---|---|---|---|
| `@capacitor/camera` | ✅ **Bắt buộc** | Chụp ảnh trực tiếp từ camera + gallery | `@capacitor/camera` |
| `@capacitor/geolocation` | ✅ **Bắt buộc** | GPS thiết bị cho bản đồ | `@capacitor/geolocation` |
| `@capacitor/splash-screen` | ✅ **Bắt buộc** | Màn hình khởi động | `@capacitor/splash-screen` |
| `@capacitor/status-bar` | ✅ **Bắt buộc** | Màu status bar đồng bộ UI | `@capacitor/status-bar` |
| `@capacitor/push-notifications` | 🔲 Tùy chọn | Thông báo bình luận, tour | `@capacitor/push-notifications` |
| `@capacitor/network` | 🔲 Tùy chọn | Kiểm tra kết nối mạng | `@capacitor/network` |
#### Cách dùng Camera plugin trong code (thay thế input file)
```typescript
// Thay thế <input type="file"> bằng Capacitor Camera API
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
const takePhoto = async () => {
const photo = await Camera.getPhoto({
resultType: CameraResultType.DataUrl, // Hoặc Uri cho hiệu năng tốt hơn
source: CameraSource.Prompt, // Hỏi: Camera hay Gallery?
quality: 85,
});
// photo.dataUrl → upload lên backend
};
```
---
### Phase 5: Điều chỉnh UI cho Mobile
Một số thành phần hiện tại đã có responsive CSS, nhưng cần kiểm tra thêm:
- **`PublicPhotoModal.tsx`**: Đã có kế hoạch fix mobile layout (từ plan cũ), cần implement trước khi build.
- **`ExploreMap.tsx`**: Leaflet map cần đảm bảo touch events hoạt động (thường OK trên mobile).
- **`TourDetailPage.tsx`**: Kiểm tra scroll behavior, fixed headers.
- **Safe Area Insets**: Dùng `env(safe-area-inset-*)` cho các thiết bị có notch/dynamic island.
```css
/* Thêm vào index.css */
:root {
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
```
---
### Phase 5b: Test trên Android Emulator (AVD)
> [!IMPORTANT]
> Đây là bước bắt buộc trước khi test trên thiết bị thật. Emulator giúp phát hiện lỗi layout, API call, và native permissions mà không cần thiết bị vật lý.
#### Tạo Android Virtual Device (AVD)
1. Mở **Android Studio → Tools → Device Manager → Create Device**
2. Chọn **Pixel 6** (hoặc tương đương) → chọn hệ thống **Android 11.0 (API 30)**
3. Cấp RAM 2GB+, Storage 4GB+ cho emulator
4. Khởi động emulator, đảm bảo hiện **"Emulator is running"**
#### Địa chỉ đặc biệt trong Emulator
```
10.0.2.2 → Trỏ đến localhost (127.0.0.1) của máy tính host
```
Khi chạy trong emulator, backend ở `localhost:3001` của máy tính phải được gọi bằng `10.0.2.2:3001`.
#### Cấu hình `capacitor.config.ts` cho Emulator Dev
```typescript
// Tạm thời uncomment khi test trên emulator
server: {
url: 'http://10.0.2.2:3002', // Trỏ đến Vite dev server trên máy host
cleartext: true, // Cho phép HTTP (không dùng trong production)
},
```
Hoặc dùng biến môi trường `.env.emulator`:
```env
VITE_API_BASE_URL=http://10.0.2.2:3001
```
#### Chạy app trên Emulator
```bash
# Bước 1: Đảm bảo emulator đang chạy
adb devices
# Phải thấy: emulator-5554 device
# Bước 2: Build & sync
npm run build -w frontend
npx cap copy android && npx cap sync android
# Bước 3: Chạy trực tiếp trên emulator
npx cap run android
# hoặc trong Android Studio: Run ▶ chọn emulator
```
#### Checklist kiểm tra trên Emulator
| Chức năng | Test case | Kết quả mong đợi |
|---|---|---|
| Khởi động | Mở app | Splash screen → Landing page |
| Đăng nhập | Nhập email/password | Vào ExploreMap |
| Bản đồ | Zoom/pan trên emulator | Leaflet map hoạt động |
| Upload ảnh | Chọn ảnh từ gallery giả lập | Ảnh được upload thành công |
| Bình luận | Nhập & gửi bình luận | Hiện real-time qua WebSocket |
| Safe area | Xoay màn hình | Layout không bị che |
| Responsive | Portrait/Landscape | UI không bị vỡ |
---
### Phase 6: Test trên thiết bị Android thật
> [!NOTE]
> Chỉ tiến hành Phase này sau khi toàn bộ Phase 5b đã pass. Thiết bị thật giúp phát hiện lỗi về hiệu năng, cảm biến thực tế, và hành vi network.
#### Kết nối thiết bị
```bash
# Bật Developer Mode + USB Debugging trên điện thoại
# Cắm cáp USB → xác nhận "Allow USB Debugging"
adb devices
# Phải thấy: <serial_number> device
```
#### Cấu hình backend cho thiết bị thật
Thiết bị thật phải dùng IP LAN hoặc ngrok (không thể dùng `10.0.2.2`):
```bash
# Tùy chọn A: Dùng IP LAN (điện thoại và máy tính cùng WiFi)
VITE_API_BASE_URL=http://192.168.x.x:3001
# Tùy chọn B: Dùng ngrok (tiện hơn, không cần cùng mạng)
ngrok http 3001
# → VITE_API_BASE_URL=https://xxxx.ngrok-free.app
```
#### Cài debug APK lên thiết bị thật
```bash
# Build debug APK
cd frontend/android && ./gradlew assembleDebug
# Cài APK lên thiết bị
adb install app/build/outputs/apk/debug/app-debug.apk
```
---
### Phase 7: Ký APK và phát hành
#### Debug build (dev testing)
```bash
cd android && ./gradlew assembleDebug
# Output: android/app/build/outputs/apk/debug/app-debug.apk
```
#### Release build (production)
```bash
# Tạo keystore lần đầu
keytool -genkey -v -keystore release-key.keystore -alias yotrip \
-keyalg RSA -keysize 2048 -validity 10000
# Build release
cd android && ./gradlew bundleRelease
# Output: .aab file để upload Google Play
```
---
## Checklist thực hiện
```
Phase 1 - Backend URL
[ ] Tạo file src/lib/api.ts (centralised fetch helper)
[ ] Refactor tất cả fetch('/api/v1/...) sang apiFetch()
[ ] Tạo .env.production với VITE_API_BASE_URL
[ ] Tạo .env.emulator với VITE_API_BASE_URL=http://10.0.2.2:3001
Phase 2 - Capacitor Setup
[ ] npm install @capacitor/core @capacitor/cli @capacitor/android
[ ] npx cap init với App ID và tên app
[ ] Tạo capacitor.config.ts
[ ] npx cap add android
Phase 3 - Build & Sync
[ ] npm run build -w frontend (kiểm tra không có lỗi TypeScript)
[ ] npx cap copy android && npx cap sync android
[ ] Cấu hình AndroidManifest.xml với permissions
Phase 4 - Native Plugins
[ ] Cài @capacitor/geolocation (thay navigator.geolocation)
[ ] Cài @capacitor/camera (upload ảnh từ điện thoại)
[ ] Cài @capacitor/splash-screen, @capacitor/status-bar
Phase 5 - Mobile UI Polish
[ ] Implement mobile layout fixes cho PublicPhotoModal.tsx
[ ] Kiểm tra safe-area-inset cho Android
Phase 5b - Test trên Android Emulator (AVD)
[ ] Tạo AVD Android 11 (API 30) trong Android Studio
[ ] Cấu hình capacitor.config.ts server.url = http://10.0.2.2:3002
[ ] Khởi động emulator → adb devices xác nhận kết nối
[ ] npx cap run android → kiểm tra app khởi động
[ ] Kiểm tra toàn bộ checklist: Login, Map, Upload, Comment, SafeArea
[ ] Sửa tất cả lỗi phát sinh trên emulator
Phase 6 - Test trên thiết bị Android 11 thật
[ ] Bật Developer Mode + USB Debugging trên điện thoại
[ ] adb devices xác nhận thiết bị kết nối
[ ] Cấu hình VITE_API_BASE_URL = IP LAN hoặc ngrok
[ ] Build debug APK → adb install
[ ] Kiểm tra lại toàn bộ checklist trên thiết bị thật
[ ] Kiểm tra performance, pin, cảm biến GPS thực tế
Phase 7 - Build Release
[ ] Build debug APK để test
[ ] Tạo keystore và build release AAB
[ ] Chuẩn bị lên Google Play (nếu cần)
```
---
## Verification Plan
### Automated
- `npm run build -w frontend` — không có lỗi TypeScript/build
- `adb devices` — xác nhận emulator/thiết bị thật đang kết nối
### Stage 1: Test trên Android Emulator
1. App khởi động không crash, hiện Landing page đúng.
2. Đăng nhập / Đăng ký hoạt động (kết nối `10.0.2.2:3001`).
3. Bản đồ Leaflet zoom/pan bằng cảm ứng mô phỏng.
4. Upload ảnh từ gallery giả lập của emulator.
5. Bình luận real-time qua WebSocket hoạt động.
6. Layout không bị vỡ khi xoay màn hình (portrait/landscape).
7. Safe-area-inset không bị che bởi status bar.
### Stage 2: Test trên thiết bị Android 11 thật
1. Lặp lại toàn bộ Stage 1 trên thiết bị thật.
2. GPS thực tế hoạt động và hiện đúng vị trí trên bản đồ.
3. Camera native chụp và upload ảnh thành công.
4. Performance mượt mà (scroll, animation không giật).
5. WebSocket giữ kết nối ổn định trên mobile network (4G/WiFi).
6. Kiểm tra pin consumption không bất thường.
---
## Các quyết định đã xác nhận
| Câu hỏi | Trả lời |
|---|---|
| Backend deploy ở đâu? | Docker trên Debian homelab, proxy qua Nginx |
| Tên app và App ID? | `YoTrip``com.yotrip.app` |
| Domain? | `yotrip.labz.io.vn` (Cloudflare DDNS) |
| Phát hành? | Đưa lên **Google Play Store** |
| Camera? | **Native camera** — chụp ảnh trực tiếp từ app |
> [!NOTE]
> **Lưu ý quan trọng về Homelab + Google Play:**
> - Homelab + Docker + Nginx là **đủ điều kiện kết nối** cho app Android.
> - Domain `yotrip.labz.io.vn` dùng **Cloudflare DDNS** — IP homelab thay đổi sẽ được cập nhật tự động.
> - **WebSocket (Socket.IO)** cần Nginx được cấu hình `proxy_set_header Upgrade` (xem Phase 1a).
> - Certbot cấp SSL cho `yotrip.labz.io.vn`: `certbot --nginx -d yotrip.labz.io.vn`
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
+1
View File
@@ -0,0 +1 @@
../.env
+39
View File
@@ -0,0 +1,39 @@
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
COPY --from=build /usr/src/app/public ./public
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());
+4
View File
@@ -1,4 +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 {};
+17 -1
View File
@@ -6,13 +6,29 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.JwtAuthGuard = void 0;
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 -1
View File
@@ -1 +1 @@
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAGtC,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B"}
{"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"}
+3 -2
View File
@@ -5,8 +5,8 @@ export declare class JwtStrategy extends JwtStrategy_base {
constructor(prisma: PrismaService);
validate(payload: any): Promise<{
id: string;
email: string;
passwordHash: string;
email: string | null;
passwordHash: string | null;
name: string | null;
phone: string | null;
address: string | null;
@@ -14,6 +14,7 @@ export declare class JwtStrategy extends JwtStrategy_base {
createdAt: Date;
isAdmin: boolean;
isBlocked: boolean;
isAnonymous: boolean;
}>;
}
export {};
+1 -1
View File
@@ -1 +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;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AApBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAoBvB"}
{"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"}
+17 -5
View File
@@ -6,6 +6,18 @@ 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 {
@@ -15,9 +27,9 @@ export declare class TourRoleGuard implements CanActivate {
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
canActivate(context: ExecutionContext): Promise<boolean>;
}
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
notifyNewComment(tourId: string, data: any): void;
export declare class EmailService {
private transporter;
constructor();
sendOTP(email: string, otp: string): Promise<any>;
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
}
+3609 -154
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+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
+5
View File
@@ -2,6 +2,7 @@
"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",
@@ -21,6 +22,7 @@
"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",
@@ -33,6 +35,9 @@
"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",
@@ -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;
+205 -8
View File
@@ -57,8 +57,8 @@ enum PrivacyLevel {
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
email String? @unique
passwordHash String?
name String?
phone String?
address String?
@@ -66,6 +66,7 @@ model User {
createdAt DateTime @default(now())
isAdmin Boolean @default(false)
isBlocked Boolean @default(false)
isAnonymous Boolean @default(false)
createdTours Tour[] @relation("TourCreator")
tourParticipations TourParticipant[]
@@ -74,7 +75,14 @@ model User {
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 {
@@ -98,6 +106,13 @@ model Tour {
joinRequests JoinRequest[]
legs Leg[]
photos Photo[]
invitations TourInvitation[]
tourMessages TourMessage[]
ratings TourRating[]
share TourShare?
notes TourNote[]
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model JoinRequest {
@@ -117,14 +132,18 @@ model JoinRequest {
}
model TourParticipant {
id String @id @default(uuid())
tourId String
userId 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)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([tourId, userId])
@@unique([tourId, userId])
}
model Leg {
@@ -161,6 +180,8 @@ model Location {
expenses Expense[]
photos Photo[]
comments Comment[]
createdAt DateTime @default(now())
}
model Expense {
@@ -188,18 +209,194 @@ model Photo {
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)
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())
}
Binary file not shown.
+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());
+41
View File
@@ -0,0 +1,41 @@
import exifr from 'exifr';
import fs from 'fs';
import path from 'path';
const searchDir = '.';
async function walk(dir) {
let files = [];
const list = fs.readdirSync(dir);
for (const file of list) {
if (file === 'node_modules' || file === '.git' || file === '.vscode') continue;
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
files = files.concat(await walk(fullPath));
} else {
if (['.jpg', '.jpeg', '.png'].includes(path.extname(file).toLowerCase())) {
files.push(fullPath);
}
}
}
return files;
}
async function run() {
const images = await walk(searchDir);
console.log(`Found ${images.length} images to scan...`);
for (const img of images) {
try {
const gps = await exifr.gps(img);
if (gps) {
console.log(`FOUND IMAGE WITH GPS: ${img}`, gps);
}
} catch (e) {
// ignore
}
}
console.log('Scan completed.');
}
run();
+39
View File
@@ -0,0 +1,39 @@
import EXIF from 'exif-js';
import exifr from 'exifr';
import fs from 'fs';
async function test() {
const files = [
'./node_modules/exif-js/example/dsc_09827.jpg',
'./node_modules/exif-js/example/DSCN0614_small.jpg',
'./node_modules/exif-js/example/Bloated-Hero.jpg',
'./node_modules/exif-js/example/Bush-dog.jpg'
];
for (const f of files) {
console.log(`--- Testing file: ${f} ---`);
try {
const gpsExifr = await exifr.gps(f);
console.log(' exifr.gps:', gpsExifr);
} catch (e) {
console.log(' exifr error:', e.message);
}
try {
const data = fs.readFileSync(f);
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
const parsed = await exifr.parse(arrayBuffer);
console.log(' exifr.parse output:', parsed ? {
latitude: parsed.latitude,
longitude: parsed.longitude,
DateTimeOriginal: parsed.DateTimeOriginal,
CreateDate: parsed.CreateDate,
ModifyDate: parsed.ModifyDate
} : 'null');
} catch (e) {
console.log(' exifr.parse error:', e.message);
}
}
}
test();
+18
View File
@@ -1,5 +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;
}
}
+2
View File
@@ -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;
}
}
+3511 -155
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);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

@@ -0,0 +1 @@
{"web":{"client_id":"639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com","project_id":"yotrip-web","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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"}}
+73
View File
@@ -0,0 +1,73 @@
services:
postgres:
image: postgres:15-alpine
container_name: yotrip-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: travel_db
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: yotrip-redis
ports:
- "6379:6379"
backend:
build:
context: ./backend
target: development
container_name: yotrip-backend
command: >
sh -c "npx prisma migrate dev --schema=prisma/schema.prisma && npm run start:dev"
ports:
- "3001:3001"
volumes:
- ./backend:/usr/src/app
- /usr/src/app/node_modules
environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
REDIS_URL: "redis://redis:6379"
PORT: 3001
ADMIN_SECRET_KEY: "yotrip_secret_admin_key"
JWT_SECRET: "super-secret"
FRONTEND_URL: "http://localhost:5173"
NODE_ENV: development
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
target: development
container_name: yotrip-frontend
command: npm run dev -- --host 0.0.0.0
ports:
- "5173:5173"
volumes:
- ./frontend:/usr/src/app
- /usr/src/app/node_modules
environment:
VITE_API_URL: "http://localhost:3001"
depends_on:
- backend
volumes:
pg_data:
+70
View File
@@ -0,0 +1,70 @@
services:
postgres:
image: postgres:15-alpine
container_name: yotrip-db-prod
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: travel_db
volumes:
- pg_data_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: yotrip-redis-prod
restart: always
backend:
build:
context: ./backend
target: production
container_name: yotrip-backend-prod
restart: always
command: >
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
ports:
- "3001:3001"
volumes:
- /mnt/storage/yotrip/uploads:/usr/src/app/uploads
- ./frontend/android/app/build/outputs/apk/debug/app-debug.apk:/usr/src/app/public/downloads/yotrip-latest.apk
environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
REDIS_URL: "redis://redis:6379"
PORT: 3001
ADMIN_SECRET_KEY: "${ADMIN_SECRET_KEY}"
JWT_SECRET: "super-secret"
FRONTEND_URL: "${FRONTEND_URL}"
NODE_ENV: production
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}"
TZ: "Asia/Ho_Chi_Minh"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
target: production
args:
- VITE_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
container_name: yotrip-frontend-prod
restart: always
ports:
- "3002:80"
depends_on:
- backend
volumes:
pg_data_prod:
+68
View File
@@ -0,0 +1,68 @@
services:
postgres:
image: postgres:15-alpine
container_name: yotrip-db-prod
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: travel_db
volumes:
- pg_data_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: yotrip-redis-prod
restart: always
backend:
build:
context: ./backend
target: production
container_name: yotrip-backend-prod
restart: always
command: >
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
ports:
- "3001:3001"
volumes:
- ./backend/uploads:/usr/src/app/uploads
environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
REDIS_URL: "redis://redis:6379"
PORT: 3001
ADMIN_SECRET_KEY: "${ADMIN_SECRET_KEY}"
JWT_SECRET: "super-secret"
FRONTEND_URL: "${FRONTEND_URL}"
NODE_ENV: production
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
target: production
args:
- VITE_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
container_name: yotrip-frontend-prod
restart: always
ports:
- "3002:80"
depends_on:
- backend
volumes:
pg_data_prod:
+87
View File
@@ -0,0 +1,87 @@
# Kế hoạch triển khai: Cấu hình Docker để Chạy Server & Phát triển (Hot-reload)
Kế hoạch này phác thảo cách cấu hình Docker và Docker Compose cho dự án YoTrip. Cấu hình này sẽ đáp ứng đồng thời hai nhu cầu:
1. **Môi trường Phát triển (Development)**: Đồng bộ mã nguồn trực tiếp (bind mounts) từ máy local vào container, hỗ trợ hot-reload cho cả backend (NestJS watch) và frontend (Vite HMR).
2. **Môi trường Triển khai (Production)**: Đóng gói tối ưu thành các image độc lập, sử dụng Nginx để phục vụ frontend tĩnh và tối ưu hóa hiệu năng NestJS backend.
---
## User Review Required
> [!IMPORTANT]
> - **Biến môi trường trong Docker**: Khi chạy trong Docker Compose, địa chỉ kết nối cơ sở dữ liệu (`DATABASE_URL`) và Redis (`REDIS_URL`) phải trỏ đến tên các service của container (ví dụ: `postgres` thay vì `localhost`). Chúng tôi sẽ cấu hình Docker Compose ghi đè (override) các biến này một cách tự động để tránh làm hỏng cấu hình chạy trực tiếp bằng `npm run start:dev` trên máy local của bạn.
> - **Cổng mạng (Ports)**:
> - Backend: Cổng `3001` được mở ra ngoài.
> - Frontend: Cổng `5173` (cho Dev) và cổng `3002` (cho Production thông qua Nginx).
> - Database: Cổng `5432` (mở để truy cập quản trị nếu cần).
> - Redis: Cổng `6379`.
---
## Proposed Changes
### 1. Dockerfile cho Backend
#### [NEW] [Dockerfile](file:///home/locpham/travelplanning/backend/Dockerfile)
- Thiết lập môi trường chạy Node.js (phiên bản `20-alpine`).
- Cài đặt các gói phụ thuộc hệ thống cần thiết (như openssl cho Prisma).
- Cấu hình chạy chế độ phát triển (sử dụng volume mounts để hot-reload) và chế độ production (build code JS).
- Chạy Prisma client generation lúc build.
---
### 2. Dockerfile cho Frontend
#### [NEW] [Dockerfile](file:///home/locpham/travelplanning/frontend/Dockerfile)
- Sử dụng chiến lược Multi-stage build để tối ưu hóa dung lượng:
- **Stage 1 (Build)**: Cài đặt dependencies và build mã nguồn React/Vite thành thư mục tĩnh `dist`.
- **Stage 2 (Nginx)**: Copy thư mục `dist` vào container chạy Nginx để phục vụ các file tĩnh ở cổng `80` (phục vụ môi trường production).
- Hỗ trợ chạy Node.js trực tiếp cho môi trường phát triển để dùng Vite dev server và HMR.
---
### 3. Docker Compose cho Phát triển & Sửa Code (Hot-reload)
#### [NEW] [docker-compose.yml](file:///home/locpham/travelplanning/docker-compose.yml)
- Khởi tạo 4 services chính:
1. `postgres`: Cơ sở dữ liệu PostgreSQL 15, lưu trữ dữ liệu bền vững qua volume `pg_data`.
2. `redis`: Caching và WebSockets.
3. `backend`: Mount thư mục `backend/` vào container, chạy lệnh `npm run start:dev` để tự động reload khi sửa code trên máy host.
4. `frontend`: Mount thư mục `frontend/` vào container, chạy lệnh `npm run dev -- --host` để phục vụ Vite dev server hỗ trợ HMR (Hot Module Replacement).
- Đồng bộ hóa các volume ẩn `node_modules` để tránh xung đột hệ điều hành giữa máy host và container.
---
### 4. Docker Compose cho Triển khai lên Server (Production)
#### [NEW] [docker-compose.prod.yml](file:///home/locpham/travelplanning/docker-compose.prod.yml)
- Cấu hình tối ưu để triển khai lên server cloud:
- Builds production image cho `backend` và chạy trực tiếp file JS đã build (`dist/src/main.js`).
- Builds production image cho `frontend` sử dụng Nginx để phục vụ client, tối ưu hóa tốc độ tải trang và bảo mật.
- Tự động restart dịch vụ nếu gặp sự cố (`restart: always`).
---
## Verification Plan
### Automated Tests
- Kiểm tra tính hợp lệ của cấu hình docker-compose:
```bash
docker compose config
```
### Manual Verification
1. **Kiểm tra Môi trường Phát triển (Sửa code trực tiếp)**:
- Chạy lệnh khởi động môi trường dev:
```bash
docker compose up --build
```
- Truy cập giao diện tại `http://localhost:5173`.
- Sửa đổi một dòng văn bản trong frontend (ví dụ: nhãn nút ở `LandingPage.tsx`) hoặc backend và kiểm tra xem container có tự động tải lại (hot-reload) tức thì hay không.
2. **Kiểm tra Môi trường Production (Triển khai server)**:
- Chạy lệnh khởi động môi trường prod:
```bash
docker compose -f docker-compose.prod.yml up --build -d
```
- Xác nhận mọi service khởi chạy ngầm thành công.
- Truy cập ứng dụng qua cổng `80` (http://localhost) và xác nhận hoạt động bình thường.
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
+2
View File
@@ -0,0 +1,2 @@
VITE_BACKEND_URL=https://yotrip.labz.io.vn
VITE_GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
+2
View File
@@ -0,0 +1,2 @@
VITE_BACKEND_URL=https://yotrip.labz.io.vn
VITE_GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
+28
View File
@@ -0,0 +1,28 @@
FROM node:22-alpine AS base
WORKDIR /usr/src/app
COPY package*.json ./
# Development stage
FROM base AS development
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# Build stage for production
FROM base AS build
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
# Production stage using Nginx
FROM nginx:1.25-alpine AS production
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace "com.yotrip.app"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.yotrip.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
@@ -0,0 +1,22 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-camera')
implementation project(':capacitor-geolocation')
implementation project(':capacitor-local-notifications')
implementation project(':codetrix-studio-capacitor-google-auth')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.location.gps" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.yotrip.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

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