merge: giải quyết xung đột env và dashboard giữa 2 máy

This commit is contained in:
2026-06-27 12:09:01 +07:00
91 changed files with 5533 additions and 412 deletions
+201
View File
@@ -0,0 +1,201 @@
# 🗺️ Hướng Dẫn Đóng Gói & Kiểm Thử Ứng Dụng Android (YoTrip)
Tài liệu này hướng dẫn chi tiết quy trình thiết lập môi trường máy tính Windows Local để biên dịch, kiểm thử dự án Frontend (React + Vite) qua Capacitor, kết nối tới hệ thống Docker Production (`https://yotrip.labz.io.vn`) và chuẩn bị phát hành lên Google Play Store.
---
## 📋 1. Điều Kiện Tiên Quyết (Môi Trường Windows)
Trước khi chạy lệnh, đảm bảo máy tính local đã cài đặt và cấu hình đầy đủ các công cụ sau:
* **Node.js:** Phiên bản v18 hoặc v20+.
* **Java JDK:** Phiên bản 17 hoặc 21 (Temurin hoặc Microsoft OpenJDK).
* Biến môi trường hệ thống: `JAVA_HOME` trỏ tới thư mục cài đặt JDK.
* Biến `Path` hệ thống: Bổ sung `%JAVA_HOME%\bin`.
* **Android Studio:** * Đã cài đặt **Android SDK**, **Android SDK Command-line Tools**.
* Biến môi trường hệ thống: `ANDROID_HOME` trỏ tới `AppData\Local\Android\Sdk`.
* Đã khởi tạo 1 thiết bị ảo (Android Simulator) qua *Virtual Device Manager*.
---
## ⚙️ 2. Cấu Hình Mã Nguồn Frontend (Local)
### 2.1 Cấu hình file `.env.production`
Tạo hoặc cập nhật file `.env.production` nằm tại thư mục gốc của `frontend/`:
```text
VITE_BACKEND_URL=[https://yotrip.labz.io.vn](https://yotrip.labz.io.vn)
### 2.2 Cấu hình Axios / API Instance (src/api/axios.ts)
Cập nhật logic baseURL để tự động phân tách môi trường chạy Web Dev (sử dụng Proxy của Vite) và môi trường chạy App Native (gọi trực tiếp URL tuyệt đối):
import axios from 'axios';
import { Capacitor } from '@capacitor/core';
const API = axios.create({
baseURL: Capacitor.isNativePlatform()
? import.meta.env.VITE_BACKEND_URL
: '',
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
});
export default API;
## 3. Cấu Hình Nền Tảng Android Native
### 3.1 Thiết lập Biểu tượng Ứng dụng (App Icon)
Để sử dụng frontend/public/favicon.ico làm icon của app trên Android, chúng ta cần chuyển đổi nó sang định dạng .png độ phân giải cao và sử dụng công cụ của Capacitor để tự động tạo các kích thước cần thiết cho Android.
Chuẩn bị ảnh: Chuyển đổi file favicon.ico của bạn thành file .png (khuyên dùng độ phân giải ít nhất 1024x1024 pixel để có chất lượng tốt nhất trên các thiết bị đời mới) và lưu tên là icon-only.png.
Cài đặt công cụ: Chạy lệnh sau tại thư mục frontend/ để cài đặt công cụ quản lý tài nguyên của Capacitor:
Bash
npm install @capacitor/assets --save-dev
Khởi tạo thư mục: Tạo thư mục assets ở thư mục gốc của frontend/ (cùng cấp với src) và đặt file icon-only.png vào đó.
Bash
mkdir assets
# Sau đó di chuyển file icon-only.png của bạn vào thư mục assets/
Tạo Icon: Chạy lệnh sau để tự động tạo và đặt các icon vào đúng vị trí trong dự án Android:
Bash
npx capacitor-assets generate --android
### 3.2 File capacitor.config.json
Định danh chính xác gói ứng dụng (App ID) dùng để đăng ký trên Google Play Console:
{
"appId": "com.yotrip.app",
"appName": "YoTrip",
"webDir": "dist",
"plugins": {
"SplashScreen": {
"launchShowDuration": 2000
}
}
}
### 3.3 File capacitor.config.json
Định danh chính xác gói ứng dụng (App ID) dùng để đăng ký trên Google Play Console:
JSON
{
"appId": "com.yotrip.app",
"appName": "YoTrip",
"webDir": "dist",
"plugins": {
"SplashScreen": {
"launchShowDuration": 2000
}
}
}
### 3.4 Cấu hình quyền trong AndroidManifest.xml
Mở đường dẫn android/app/src/main/AndroidManifest.xml, thêm các quyền truy cập Internet, định vị GPS, và các quyền cần thiết cho tính năng chụp ảnh và lưu ảnh vào bộ nhớ máy:
XML
<manifest xmlns:android="http://schemas.microsoft.com/apk/res/android">
<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" />
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
</queries>
<application
...
android:usesCleartextTraffic="true"> ...
</application>
</manifest>
## 4. Tích Hợp Tính Năng Chụp Ảnh và Lưu Ảnh (React Code)
Sử dụng Plugin của Capacitor để tích hợp trực tiếp vào code React của bạn.
Cài đặt Plugin Camera: Chạy lệnh sau tại thư mục frontend/:
Bash
npm install @capacitor/camera
npx cap update
Ví dụ code: Dưới đây là cách implement tính năng chụp ảnh và tự động lưu ảnh gốc vào thư viện ảnh của điện thoại trong một React component (ví dụ src/components/PhotoTaker.tsx):
JavaScript
import React, { useState } from 'react';
import { IonButton, IonIcon, IonContent, IonPage } from '@ionic/react';
import { camera } from 'ionicons/icons';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
const PhotoTaker: React.FC = () => {
const [photoUri, setPhotoUri] = useState<string | undefined>();
const takeAndSavePhoto = async () => {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: false, // Giữ nguyên ảnh gốc, không qua chỉnh sửa
resultType: CameraResultType.Uri,
source: CameraSource.Camera, // Mở camera trực tiếp
saveToGallery: true, // YÊU CẦU MỚI: Tự động lưu ảnh gốc vào thư viện điện thoại
});
// Bạn có thể sử dụng image.webPath để hiển thị xem trước
setPhotoUri(image.webPath);
console.log('Ảnh đã được chụp và lưu tại:', image.path);
} catch (error) {
console.error('Lỗi khi chụp hoặc lưu ảnh:', error);
}
};
return (
<IonPage>
<IonContent className="ion-padding">
<div className="flex flex-col items-center gap-4">
<h1 className="text-xl font-bold">Tính năng Chụp ảnh</h1>
<IonButton onClick={takeAndSavePhoto} color="primary">
<IonIcon slot="start" icon={camera}></IonIcon>
Chụp và Lưu Ảnh Gốc
</IonButton>
{photoUri && (
<div className="mt-4 border p-2">
<p>Xem trước ảnh vừa chụp:</p>
<img src={photoUri} alt="Xem trước ảnh chụp" className="max-w-xs mt-2" />
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default PhotoTaker;
## 5. Quy Trình Biên Dịch & Kiểm Thử (Simulator)
Mỗi lần cập nhật code giao diện ở máy local, chạy chuỗi lệnh sau tại Terminal của VS Code để đẩy app lên máy ảo:
# Bước 1: Cài đặt các thư viện phụ thuộc tại local
npm install
# Bước 2: Build code React + Vite thành file tĩnh
npm run build
# Bước 3: Đồng bộ mã nguồn tĩnh vào thư mục Android mã nguồn mở
npx cap sync
# Bước 4: Khởi chạy máy ảo và nạp ứng dụng tự động
npx cap run android
@@ -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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"}}
+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.
+1 -2
View File
@@ -1,3 +1,2 @@
VITE_API_URL=http://localhost:3001
DATABASE_URL=postgresql://postgres:password@localhost:5432/travel_db?schema=public
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
@@ -9,7 +9,10 @@ android {
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')
}
@@ -1,14 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<?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:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
@@ -16,12 +15,10 @@
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
@@ -29,13 +26,25 @@
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
<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.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
</queries>
</manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

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.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

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.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 23 KiB

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 39 KiB

@@ -4,4 +4,5 @@
<string name="title_activity_main">YoTrip</string>
<string name="package_name">com.yotrip.app</string>
<string name="custom_url_scheme">com.yotrip.app</string>
<string name="server_client_id">639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com</string>
</resources>
+9
View File
@@ -24,6 +24,15 @@ allprojects {
}
}
subprojects {
configurations.all {
resolutionStrategy {
force "androidx.core:core-ktx:${rootProject.ext.androidxCoreVersion}"
force "androidx.core:core:${rootProject.ext.androidxCoreVersion}"
}
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
@@ -2,5 +2,14 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
include ':capacitor-camera'
project(':capacitor-camera').projectDir = new File('../../node_modules/@capacitor/camera/android')
include ':capacitor-geolocation'
project(':capacitor-geolocation').projectDir = new File('../../node_modules/@capacitor/geolocation/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../../node_modules/@capacitor/local-notifications/android')
include ':codetrix-studio-capacitor-google-auth'
project(':codetrix-studio-capacitor-google-auth').projectDir = new File('../../node_modules/@codetrix-studio/capacitor-google-auth/android')
+1 -1
View File
@@ -1,5 +1,5 @@
ext {
minSdkVersion = 23
minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 35
androidxActivityVersion = '1.9.2'
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

+11 -1
View File
@@ -3,7 +3,17 @@ import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.yotrip.app',
appName: 'YoTrip',
webDir: 'dist'
webDir: 'dist',
plugins: {
SplashScreen: {
launchShowDuration: 2000
},
GoogleAuth: {
scopes: ['profile', 'email'],
serverClientId: '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com',
forceCodeForRefreshToken: true
}
}
};
export default config;
+10 -2
View File
@@ -21,8 +21,16 @@
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
<script type="module" crossorigin src="/assets/index-D0YTcNWm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cnj-xh6m.css">
<script type="module" crossorigin src="/assets/index-BximWk33.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Cyuzqnbw.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-DOXkNaRS.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-maps-DY-S_hoR.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-react-DQOuwrxr.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-IjT8x9OX.js">
<link rel="stylesheet" crossorigin href="/assets/vendor-maps-CcXJxNtP.css">
<link rel="stylesheet" crossorigin href="/assets/vendor-react-CuSj0z1j.css">
<link rel="stylesheet" crossorigin href="/assets/index-21UejyBs.css">
</head>
<body>
<div id="root"></div>
+4
View File
@@ -11,9 +11,12 @@
},
"dependencies": {
"@capacitor/android": "^8.4.1",
"@capacitor/camera": "^8.2.0",
"@capacitor/cli": "^7.6.7",
"@capacitor/core": "^8.4.1",
"@capacitor/geolocation": "^8.2.0",
"@capacitor/local-notifications": "^8.2.0",
"@codetrix-studio/capacitor-google-auth": "^3.4.0-rc.4",
"date-fns": "^4.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
@@ -29,6 +32,7 @@
"zustand": "^5.0.1"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@tailwindcss/postcss": "^4.3.1",
"@types/leaflet": "^1.9.12",
"@types/react": "^18.3.12",
+4 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import { useTourStore } from '@/store/useTourStore';
import { ConfirmModal } from './ConfirmModal';
@@ -69,7 +70,9 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
fetchComments();
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
const socket = io();
const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
: io();
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
socket.on('commentAdded', (newCommentData: any) => {
+106 -5
View File
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2, LogIn } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { Capacitor } from '@capacitor/core';
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
interface JoinTourLoginModalProps {
isOpen: boolean;
@@ -119,6 +121,88 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
}
};
const handleNativeGoogleLogin = async () => {
setError('');
setIsLoading(true);
try {
const user = await GoogleAuth.signIn();
console.log('[JoinTourLogin] Native Google user received:', user);
const idToken = user.authentication.idToken;
if (!idToken) {
throw new Error('Không nhận được token từ Google.');
}
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: idToken }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập Google thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
let currentToken = inviteTokenRef.current || localStorage.getItem('pendingInviteToken');
if (!currentToken) {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
if (urlToken) {
currentToken = urlToken;
localStorage.setItem('pendingInviteToken', urlToken);
}
}
if (!currentToken) {
throw new Error('Không có mã lời mời để tham gia tour');
}
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: currentToken }),
});
const joinData = await joinRes.json().catch(() => ({}));
if (joinRes.ok) {
notify({
title: 'Thành công',
message: joinData.message || 'Bạn đã gia nhập tour!',
type: 'success'
});
localStorage.removeItem('pendingInviteToken');
if (onJoinSuccess) {
onJoinSuccess(data.user, joinData);
}
onClose();
} else {
localStorage.removeItem('pendingInviteToken');
const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`;
setError(`Lỗi gia nhập tour: ${errorMessage}`);
setIsLoading(false);
}
} catch (e: any) {
setError(`Lỗi khi gia nhập: ${e.message}`);
setIsLoading(false);
}
} catch (err: any) {
console.error('[JoinTourLogin] Native Google login error:', err);
if (err?.message !== 'Sign in window was closed' && err?.message !== '12501') {
setError(err.message || 'Đăng nhập Google thất bại');
}
setIsLoading(false);
}
};
const handleEmailPasswordJoin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
@@ -182,7 +266,7 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
};
useEffect(() => {
if (!isOpen) return;
if (!isOpen || Capacitor.isNativePlatform()) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
@@ -241,10 +325,27 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
</div>
)}
{/* Google OAuth Button */}
<div className="mb-4 sm:mb-6 flex justify-center">
<div id="google-signin-btn-join-tour" className="w-full" />
</div>
{Capacitor.isNativePlatform() ? (
<div className="mb-4 sm:mb-6 flex justify-center w-full">
<button
type="button"
onClick={handleNativeGoogleLogin}
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-slate-50 text-slate-800 font-bold py-3.5 px-4 rounded-2xl border border-slate-200 transition-all active:scale-[0.98] cursor-pointer text-sm shadow-sm"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="#EA4335"
d="M12.24 10.285V14.4h6.887c-.648 2.41-2.519 4.114-5.136 4.114-3.51 0-6.357-2.89-6.357-6.457 0-3.568 2.848-6.458 6.357-6.458 1.614 0 3.08.618 4.19 1.625l3.03-3.03C19.045 2.11 15.895 1 12.24 1 6.033 1 1 6.033 1 12.24s5.033 11.24 11.24 11.24c5.84 0 10.74-4.14 11.24-9.84v-3.355H12.24z"
/>
</svg>
Đăng nhập bằng Google
</button>
</div>
) : (
<div className="mb-4 sm:mb-6 flex justify-center">
<div id="google-signin-btn-join-tour" className="w-full" />
</div>
)}
<div className="relative mb-4 sm:mb-6">
<div className="absolute inset-0 flex items-center">
+82 -2
View File
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
import { Capacitor } from '@capacitor/core';
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
interface LoginModalProps {
isOpen: boolean;
@@ -101,8 +103,70 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
}
};
const handleNativeGoogleLogin = async () => {
setError('');
setIsLoading(true);
try {
const user = await GoogleAuth.signIn();
console.log('[Native OAuth] Google user received:', user);
const idToken = user.authentication.idToken;
if (!idToken) {
throw new Error('Không nhận được token từ Google.');
}
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: idToken }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập Google thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
console.log('[Native OAuth] Google login successful');
let pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: pendingInviteToken }),
});
if (joinRes.ok) {
localStorage.removeItem('pendingInviteToken');
}
} catch (joinErr) {
console.error('[Native OAuth] Join tour after login failed:', joinErr);
}
}
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
} catch (err: any) {
console.error('[Native OAuth] Error:', err);
if (err?.message !== 'Sign in window was closed' && err?.message !== '12501') {
setError(err.message || 'Đăng nhập Google thất bại');
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
if (!isOpen || Capacitor.isNativePlatform()) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
@@ -244,7 +308,23 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
<span className="relative px-3 bg-[var(--surface)] text-xs font-bold text-[var(--text-muted)] uppercase">Hoặc</span>
</div>
<div id="google-signin-btn-login" className="w-full flex justify-center"></div>
{Capacitor.isNativePlatform() ? (
<button
type="button"
onClick={handleNativeGoogleLogin}
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-slate-50 text-slate-800 font-bold py-3.5 px-4 rounded-2xl border border-slate-200 transition-all active:scale-[0.98] cursor-pointer text-sm shadow-sm"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="#EA4335"
d="M12.24 10.285V14.4h6.887c-.648 2.41-2.519 4.114-5.136 4.114-3.51 0-6.357-2.89-6.357-6.457 0-3.568 2.848-6.458 6.357-6.458 1.614 0 3.08.618 4.19 1.625l3.03-3.03C19.045 2.11 15.895 1 12.24 1 6.033 1 1 6.033 1 12.24s5.033 11.24 11.24 11.24c5.84 0 10.74-4.14 11.24-9.84v-3.355H12.24z"
/>
</svg>
Đăng nhập bằng Google
</button>
) : (
<div id="google-signin-btn-login" className="w-full flex justify-center"></div>
)}
<div className="mt-10 pt-8 border-t border-[var(--border)] text-center">
<p className="text-[var(--text-secondary)]">
+73
View File
@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { Camera as CameraIcon, X } from 'lucide-react';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { useNotification } from '@/hooks/useNotification';
interface PhotoTakerProps {
onPhotoTaken?: (webPath: string) => void;
onClose?: () => void;
}
export const PhotoTaker: React.FC<PhotoTakerProps> = ({ onPhotoTaken, onClose }) => {
const [photoUri, setPhotoUri] = useState<string | undefined>();
const notify = useNotification();
const takeAndSavePhoto = async () => {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: false, // Giữ nguyên ảnh gốc, không qua chỉnh sửa
resultType: CameraResultType.Uri,
source: CameraSource.Camera, // Mở camera trực tiếp
saveToGallery: true, // Tự động lưu ảnh gốc vào thư viện điện thoại
});
setPhotoUri(image.webPath);
notify({
title: 'Thành công',
message: 'Ảnh đã được chụp và tự động lưu vào thư viện điện thoại.',
type: 'success'
});
if (onPhotoTaken && image.webPath) {
onPhotoTaken(image.webPath);
}
} catch (error: any) {
console.error('Lỗi khi chụp hoặc lưu ảnh:', error);
if (error?.message !== 'User cancelled photos app') {
notify({
title: 'Lỗi chụp ảnh',
message: 'Không thể truy cập camera hoặc lưu ảnh.',
type: 'error'
});
}
}
};
return (
<div className="flex flex-col items-center justify-center p-6 bg-slate-900/60 border border-slate-800/80 rounded-2xl w-full max-w-md mx-auto backdrop-blur-md">
<div className="flex justify-between items-center w-full mb-4">
<h3 className="text-sm font-black uppercase text-indigo-400 tracking-wider">Chụp nh hành trình</h3>
{onClose && (
<button onClick={onClose} className="p-1 hover:bg-slate-800 rounded-full transition-colors text-slate-400 hover:text-white">
<X className="w-5 h-5" />
</button>
)}
</div>
<button
onClick={takeAndSavePhoto}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-indigo-650 hover:bg-indigo-700 active:scale-[0.98] text-white rounded-xl font-bold transition-all shadow-md cursor-pointer text-xs uppercase tracking-wider"
>
<CameraIcon className="w-4 h-4" />
Chụp Lưu nh Gốc
</button>
{photoUri && (
<div className="mt-6 w-full text-center border border-slate-800/85 bg-slate-950/40 p-4 rounded-xl">
<p className="text-[11px] text-slate-400 mb-2 font-semibold">Xem trước nh vừa chụp:</p>
<img src={photoUri} alt="Xem trước ảnh chụp" className="max-w-full h-auto rounded-lg mx-auto border border-slate-800" />
</div>
)}
</div>
);
};
+4 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import { CoordinateSelectModal } from './CoordinateSelectModal';
import { useTranslation } from '../hooks/useTranslation';
import { useConfirm } from '../hooks/useConfirm';
@@ -246,7 +247,9 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
fetchComments();
const socket = io();
const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
: io();
socket.emit('joinPhoto', photo.id);
socket.on('photoCommentAdded', (newCommentData: any) => {
+42 -3
View File
@@ -1,5 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
@@ -87,7 +89,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
}, []);
const filteredParticipants = participants.filter(p =>
p.name.toLowerCase().includes(mentionSearch.toLowerCase())
p && p.name && p.name.toLowerCase().includes(mentionSearch.toLowerCase())
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -179,7 +181,9 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
// Connect to socket and listen for tour messages
useEffect(() => {
const socket = io();
const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
: io();
socketRef.current = socket;
socket.on('connect', () => {
@@ -252,6 +256,41 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
});
};
// Handle Native Image Selection (Camera or Library)
const handleNativePhotoSelect = async () => {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: false,
resultType: CameraResultType.Uri,
source: CameraSource.Prompt, // Prompt selection
saveToGallery: true, // Auto-save original photo to device gallery
promptLabelHeader: 'Chọn hình ảnh',
promptLabelPhoto: 'Chọn từ thư viện',
promptLabelPicture: 'Chụp ảnh mới (Camera)'
});
if (image && image.webPath) {
setImagePreview(image.webPath);
// Convert Capacitor webPath resource back to standard File instance
const response = await fetch(image.webPath);
const blob = await response.blob();
const file = new File([blob], `photo.${image.format}`, { type: `image/${image.format}` });
setSelectedImage(file);
}
} catch (error: any) {
console.error('Lỗi chọn ảnh native:', error);
if (error?.message !== 'User cancelled photos app' && error?.message !== 'User cancelled camera') {
notify({
title: 'Lỗi',
message: 'Không thể truy cập máy ảnh hoặc thư viện ảnh.',
type: 'error'
});
}
}
};
// Handle Image Selection
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -613,7 +652,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
{/* Attach photo button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onClick={Capacitor.isNativePlatform() ? handleNativePhotoSelect : () => fileInputRef.current?.click()}
className="p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0"
title="Đính kèm hình ảnh"
>
+20
View File
@@ -1,5 +1,7 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { NotificationModal } from '../components/NotificationModal';
import { Capacitor } from '@capacitor/core';
import { LocalNotifications } from '@capacitor/local-notifications';
interface NotificationOptions {
title: string;
@@ -24,6 +26,24 @@ export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({
message,
type,
});
if (Capacitor.isNativePlatform()) {
LocalNotifications.schedule({
notifications: [
{
title: title || 'YoTrip',
body: message,
id: Math.floor(Math.random() * 1000000),
schedule: { at: new Date(Date.now() + 100) },
sound: 'default',
actionTypeId: 'OPEN_APP',
extra: null
}
]
}).catch(err => {
console.error('[LocalNotifications] Error scheduling native notification:', err);
});
}
}, []);
const handleClose = useCallback(() => {
+1
View File
@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import './utils/nativePatch';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(
+187 -197
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import {
Compass,
Users,
@@ -527,8 +528,18 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
useEffect(() => {
if (!user?.id) return;
const handleMessageReceived = (e: Event) => {
const message = (e as CustomEvent).detail;
// Connect to WebSocket using same origin/proxy
const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
: io();
socketRef.current = socket;
socket.on('connect', () => {
console.log('[WS] MemberDashboard connected:', socket.id);
socket.emit('joinUser', user.id);
});
socket.on('messageReceived', (message: any) => {
// If we are actively chatting with the sender of this message
if (activeChatUser && (message.senderId === activeChatUser.id || message.receiverId === activeChatUser.id)) {
setChatMessages(prev => [...prev, message]);
@@ -827,7 +838,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
};
// Helper function to render a user's initials avatar
const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') => {
const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') => {
const initials = name
? name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()
: 'U';
@@ -876,11 +887,10 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
type: 'info'
});
}}
className={`absolute -bottom-1 -right-1 p-1.5 rounded-full border shadow-md transition-all active:scale-95 z-20 ${
muteNotifications
className={`absolute -bottom-1 -right-1 p-1.5 rounded-full border shadow-md transition-all active:scale-95 z-20 ${muteNotifications
? 'bg-slate-900 border-slate-800 text-slate-400 hover:text-slate-200'
: 'bg-indigo-650 border-indigo-500 text-white hover:bg-indigo-600'
}`}
}`}
title={muteNotifications ? "Bật thông báo đẩy" : "Tắt thông báo đẩy"}
>
{muteNotifications ? (
@@ -944,11 +954,10 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
<div className="flex-1 p-4 flex flex-col gap-3">
<button
onClick={() => handleSelectTab('tours')}
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${
activeTab === 'tours'
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'tours'
? 'bg-indigo-900/40 border-indigo-500/40'
: 'bg-slate-850/80 border-slate-700/80'
}`}
}`}
>
<div className="flex items-center gap-3">
<Compass className="w-5 h-5 text-indigo-400" />
@@ -972,11 +981,10 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
<button
onClick={() => handleSelectTab('photos')}
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${
activeTab === 'photos'
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'photos'
? 'bg-indigo-900/40 border-indigo-500/40'
: 'bg-slate-850/80 border-slate-700/80'
}`}
}`}
>
<div className="flex items-center gap-3">
<ImageIcon className="w-5 h-5 text-indigo-400" />
@@ -998,11 +1006,10 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
<div className="p-4 flex flex-col gap-3">
<button
onClick={() => handleSelectTab('connections')}
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${
activeTab === 'connections'
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'connections'
? 'bg-indigo-900/40 border-indigo-500/40'
: 'bg-slate-900/60 border-slate-800/60'
}`}
}`}
>
<div className="flex items-center gap-3">
<Users className="w-5 h-5 text-indigo-400" />
@@ -1026,11 +1033,10 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
<button
onClick={() => handleSelectTab('chats')}
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${
activeTab === 'chats'
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'chats'
? 'bg-indigo-900/40 border-indigo-500/40'
: 'bg-slate-900/60 border-slate-800/60'
}`}
}`}
>
<div className="flex items-center gap-3">
<MessageSquare className="w-5 h-5 text-indigo-400" />
@@ -1069,18 +1075,17 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
);
}
return (
return (
<div className="h-auto min-h-dvh w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-white flex flex-col md:flex-row p-3 sm:p-6 gap-4 sm:gap-6 overflow-x-hidden">
{/* Background Glow */}
<div className="hidden md:block absolute top-0 right-0 w-[500px] h-[500px] bg-indigo-500/5 rounded-full blur-[120px] pointer-events-none z-0"></div>
<div className="hidden md:block absolute bottom-0 left-0 w-[500px] h-[500px] bg-purple-500/5 rounded-full blur-[120px] pointer-events-none z-0"></div>
<div className={`w-full relative z-10 flex overflow-hidden transition-all duration-350 ${
isMobile
<div className={`w-full relative z-10 flex overflow-hidden transition-all duration-350 ${isMobile
? 'flex-col h-full bg-slate-950'
: 'max-w-7xl h-[90vh] bg-slate-900/30 border border-slate-800/80 shadow-2xl rounded-3xl backdrop-blur-md'
}`}>
}`}>
{/* Mobile Detail Header: Only on Mobile detail mode */}
{showMobileHeader && (
@@ -1133,11 +1138,10 @@ return (
type: 'info'
});
}}
className={`absolute -bottom-1 -right-1 p-1.5 rounded-full border shadow-md transition-all active:scale-90 z-20 ${
muteNotifications
className={`absolute -bottom-1 -right-1 p-1.5 rounded-full border shadow-md transition-all active:scale-90 z-20 ${muteNotifications
? 'bg-slate-900 border-slate-800 text-slate-400 hover:text-slate-200'
: 'bg-indigo-650 border-indigo-500 text-white hover:bg-indigo-600'
}`}
}`}
title={muteNotifications ? "Bật thông báo đẩy" : "Tắt thông báo đẩy"}
>
{muteNotifications ? (
@@ -1199,11 +1203,10 @@ return (
<nav className="flex-1 px-3 py-6 flex flex-col gap-1.5 overflow-y-auto">
<button
onClick={() => handleSelectTab('tours')}
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${
activeTab === 'tours'
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'tours'
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
}`}
}`}
>
<div className="flex items-center gap-3">
<Compass className="w-4 h-4" />
@@ -1219,11 +1222,10 @@ return (
<button
onClick={() => handleSelectTab('photos')}
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${
activeTab === 'photos'
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'photos'
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
}`}
}`}
>
<div className="flex items-center gap-3">
<ImageIcon className="w-4 h-4" />
@@ -1239,11 +1241,10 @@ return (
<button
onClick={() => handleSelectTab('chats')}
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${
activeTab === 'chats'
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'chats'
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
}`}
}`}
>
<div className="flex items-center gap-3">
<MessageSquare className="w-4 h-4" />
@@ -1258,11 +1259,10 @@ return (
<button
onClick={() => handleSelectTab('connections')}
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${
activeTab === 'connections'
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'connections'
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
}`}
}`}
>
<div className="flex items-center gap-3">
<Users className="w-4 h-4" />
@@ -1306,11 +1306,10 @@ return (
)}
{/* Tab content renders here */}
<div className={`flex-1 relative z-10 flex flex-col ${
activeTab === 'chats' || activeTab === 'photos'
<div className={`flex-1 relative z-10 flex flex-col ${activeTab === 'chats' || activeTab === 'photos'
? 'overflow-hidden p-0'
: 'p-4 md:p-8 overflow-y-auto'
}`}>
}`}>
{/* TAB 1: MY TOURS */}
{activeTab === 'tours' && (
@@ -1353,7 +1352,7 @@ return (
const participant = tour.participants?.find((p: any) => p.userId === user?.id);
const roleLabel = participant?.role === 'OWNER' ? 'Chủ tour' :
participant?.role === 'MANAGER' ? 'Quản lý' : 'Thành viên';
participant?.role === 'MANAGER' ? 'Quản lý' : 'Thành viên';
return (
<div
@@ -1444,13 +1443,13 @@ return (
<span>Chụp nh</span>
</button>
<button
onClick={() => handleNavigateToItinerary(tour)}
className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-850 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1"
>
<span>Chi tiết hành trình</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleNavigateToItinerary(tour)}
className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-850 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1"
>
<span>Chi tiết hành trình</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
{/* Emergency Share Button - moved to bottom */}
@@ -1502,31 +1501,28 @@ return (
<div className="flex gap-2 border-b border-slate-800/60 pb-3 mb-6">
<button
onClick={() => setConnectionSubTab('list')}
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all ${
connectionSubTab === 'list'
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all ${connectionSubTab === 'list'
? 'bg-indigo-600 text-white shadow-md'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
}`}
}`}
>
Danh sách kết nối
</button>
<button
onClick={() => setConnectionSubTab('search')}
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
connectionSubTab === 'search'
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${connectionSubTab === 'search'
? 'bg-indigo-600 text-white shadow-md'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
}`}
}`}
>
<Search className="w-3.5 h-3.5" /> Tìm thành viên mới
</button>
<button
onClick={() => setConnectionSubTab('pending')}
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all relative ${
connectionSubTab === 'pending'
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all relative ${connectionSubTab === 'pending'
? 'bg-indigo-600 text-white shadow-md'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
}`}
}`}
>
Yêu cầu chờ duyệt
{receivedRequests.length > 0 && (
@@ -1575,11 +1571,10 @@ return (
<h4 className="text-sm font-bold text-white truncate">{connUser.name}</h4>
<p className="text-[10px] text-slate-400 truncate">{connUser.email}</p>
<div className="mt-1.5 flex items-center gap-1.5">
<span className={`px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-wider border ${
conn.type === 'FAMILY'
<span className={`px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-wider border ${conn.type === 'FAMILY'
? 'bg-rose-950/40 text-rose-300 border-rose-900/50'
: 'bg-indigo-950/40 text-indigo-300 border-indigo-900/50'
}`}>
}`}>
{conn.type === 'FAMILY' ? t('familyGroup') : t('friends')}
</span>
</div>
@@ -1683,11 +1678,10 @@ return (
<div>
{statusText ? (
<span className={`px-3 py-1 rounded-lg text-xs font-bold border ${
statusText === t('friends') || statusText === t('familyGroup')
<span className={`px-3 py-1 rounded-lg text-xs font-bold border ${statusText === t('friends') || statusText === t('familyGroup')
? 'bg-emerald-950/30 border-emerald-900/50 text-emerald-300'
: 'bg-slate-800/80 border-slate-700 text-slate-400'
}`}>
}`}>
{statusText}
</span>
) : (
@@ -1822,9 +1816,9 @@ return (
</div>
)}
{/* TAB 4: REALTIME CHAT */}
{activeTab === 'chats' && (
<div className="flex flex-col h-[100dvh] md:h-auto w-full bg-slate-900/40 border-0 md:border border-slate-800/80 md:rounded-3xl overflow-hidden flex animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* TAB 4: REALTIME CHAT */}
{activeTab === 'chats' && (
<div className="flex flex-col h-[100dvh] md:h-auto w-full bg-slate-900/40 border-0 md:border border-slate-800/80 md:rounded-3xl overflow-hidden flex animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Chats List sidebar: show if not mobile OR if mobile and no active chat user */}
{(!isMobile || !activeChatUser) && (
@@ -1847,11 +1841,10 @@ return (
<button
key={conn.id}
onClick={() => handleSelectChatUser(connUser)}
className={`w-full p-3 rounded-xl flex items-center gap-3 transition-all text-left ${
isActive
className={`w-full p-3 rounded-xl flex items-center gap-3 transition-all text-left ${isActive
? 'bg-indigo-650 text-white shadow-md'
: 'text-slate-350 hover:bg-slate-850/40 hover:text-slate-100'
}`}
}`}
>
{connUser.avatar ? (
<img
@@ -1935,11 +1928,10 @@ return (
key={msg.id}
className={`flex flex-col max-w-[70%] ${isMe ? 'self-end items-end' : 'self-start items-start'}`}
>
<div className={`p-3.5 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${
isMe
<div className={`p-3.5 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${isMe
? 'bg-indigo-650 text-white rounded-br-none shadow-md shadow-indigo-950/20'
: 'bg-slate-800 text-slate-200 rounded-bl-none border border-slate-700/60'
}`}>
}`}>
{msg.attachmentUrl && (
<div className="relative rounded-lg overflow-hidden border border-black/10 max-w-xs group/img">
<img
@@ -1963,11 +1955,10 @@ return (
href={`https://www.google.com/maps/search/?api=1&query=${msg.latitude},${msg.longitude}`}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${
isMe
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${isMe
? 'bg-indigo-750 border-indigo-750/30 text-indigo-100 hover:bg-indigo-800'
: 'bg-slate-900/60 border-slate-800/80 text-slate-200 hover:bg-slate-900'
}`}
}`}
>
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
<div className="flex flex-col text-left">
@@ -2055,9 +2046,8 @@ return (
type="button"
onClick={handleGetLocation}
disabled={isLocating}
className={`p-3 bg-slate-900 border border-slate-800 hover:bg-slate-855 text-slate-400 hover:text-white rounded-2xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0 ${
isLocating ? 'animate-pulse' : ''
}`}
className={`p-3 bg-slate-900 border border-slate-800 hover:bg-slate-855 text-slate-400 hover:text-white rounded-2xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0 ${isLocating ? 'animate-pulse' : ''
}`}
title="Chia sẻ vị trí GPS hiện tại"
>
{isLocating ? (
@@ -2099,128 +2089,128 @@ return (
</div>
)}
{/* Emergency Share Configuration Modal */}
{sharingTour && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300">
<div className="w-full max-w-lg bg-slate-900 border border-slate-800 rounded-[32px] overflow-hidden shadow-2xl animate-in zoom-in-95 duration-300">
{/* Modal Header */}
<div className="p-6 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-3 text-rose-500">
<ShieldAlert className="w-6 h-6 animate-pulse" />
<h3 className="text-lg font-black uppercase tracking-tight text-white">{t('emergencyShare')}</h3>
</div>
<button
onClick={() => setSharingTour(null)}
className="p-2 hover:bg-slate-800 rounded-xl text-slate-400 hover:text-white transition-all active:scale-95"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */}
<div className="p-6 space-y-6">
<div>
<h4 className="text-sm font-bold text-white mb-1">{sharingTour.title}</h4>
<p className="text-xs text-slate-450 leading-relaxed">{t('emergencyShareTooltip')}</p>
</div>
{loadingShare ? (
<div className="py-8 flex flex-col items-center justify-center gap-2">
<Loader2 className="w-8 h-8 text-rose-500 animate-spin" />
<span className="text-xs text-slate-500 font-bold">{t('loading')}</span>
</div>
) : (
<>
{/* Share Activation Toggle */}
<div className="bg-slate-950/40 border border-slate-800/60 p-4 rounded-2xl flex items-center justify-between">
<div>
<span className="text-xs font-bold text-slate-300">Kích hoạt đưng dẫn cứu hộ</span>
{/* Emergency Share Configuration Modal */}
{sharingTour && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300">
<div className="w-full max-w-lg bg-slate-900 border border-slate-800 rounded-[32px] overflow-hidden shadow-2xl animate-in zoom-in-95 duration-300">
{/* Modal Header */}
<div className="p-6 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-3 text-rose-500">
<ShieldAlert className="w-6 h-6 animate-pulse" />
<h3 className="text-lg font-black uppercase tracking-tight text-white">{t('emergencyShare')}</h3>
</div>
{shareStatus && (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={shareStatus.isEnabled}
onChange={(e) => handleToggleShare(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-rose-600"></div>
</label>
<button
onClick={() => setSharingTour(null)}
className="p-2 hover:bg-slate-800 rounded-xl text-slate-400 hover:text-white transition-all active:scale-95"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */}
<div className="p-6 space-y-6">
<div>
<h4 className="text-sm font-bold text-white mb-1">{sharingTour.title}</h4>
<p className="text-xs text-slate-450 leading-relaxed">{t('emergencyShareTooltip')}</p>
</div>
{loadingShare ? (
<div className="py-8 flex flex-col items-center justify-center gap-2">
<Loader2 className="w-8 h-8 text-rose-500 animate-spin" />
<span className="text-xs text-slate-500 font-bold">{t('loading')}</span>
</div>
) : (
<>
{/* Share Activation Toggle */}
<div className="bg-slate-950/40 border border-slate-800/60 p-4 rounded-2xl flex items-center justify-between">
<div>
<span className="text-xs font-bold text-slate-300">Kích hoạt đưng dẫn cứu hộ</span>
</div>
{shareStatus && (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={shareStatus.isEnabled}
onChange={(e) => handleToggleShare(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-rose-600"></div>
</label>
)}
</div>
{shareStatus?.isEnabled && (
<>
{/* Configuration: Language and Theme selectors */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase tracking-widest text-slate-500">{t('languageSelect')}</label>
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2.5 text-xs font-bold focus:outline-none cursor-pointer"
>
<option value="vi">Tiếng Việt</option>
<option value="en">English</option>
<option value="zh"></option>
</select>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase tracking-widest text-slate-500">{t('themeSelect')}</label>
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2.5 text-xs font-bold focus:outline-none cursor-pointer"
>
<option value="light">{t('themeLight')}</option>
<option value="dark">{t('themeDark')}</option>
<option value="system">{t('themeSystem')}</option>
</select>
</div>
</div>
{/* Shareable Link Input with Copy button */}
<div className="bg-rose-950/10 border border-rose-950/20 p-4 rounded-2xl space-y-2">
<div className="text-[10px] font-black text-rose-400 uppercase tracking-widest">Đưng dẫn khẩn cấp:</div>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${window.location.origin}/journey/${shareStatus.token}?lang=${lang}&theme=${theme}`}
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 text-xs text-slate-200 select-all outline-none"
/>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/journey/${shareStatus.token}?lang=${lang}&theme=${theme}`);
notify({ title: 'Thành công', message: 'Đã sao chép liên kết!', type: 'success' });
}}
className="bg-rose-600 hover:bg-rose-700 text-white font-bold px-4 py-2.5 rounded-xl text-xs transition-all active:scale-95 shrink-0"
>
{t('copyShareLink')}
</button>
</div>
</div>
</>
)}
</>
)}
</div>
{shareStatus?.isEnabled && (
<>
{/* Configuration: Language and Theme selectors */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase tracking-widest text-slate-500">{t('languageSelect')}</label>
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2.5 text-xs font-bold focus:outline-none cursor-pointer"
>
<option value="vi">Tiếng Việt</option>
<option value="en">English</option>
<option value="zh"></option>
</select>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase tracking-widest text-slate-500">{t('themeSelect')}</label>
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2.5 text-xs font-bold focus:outline-none cursor-pointer"
>
<option value="light">{t('themeLight')}</option>
<option value="dark">{t('themeDark')}</option>
<option value="system">{t('themeSystem')}</option>
</select>
</div>
</div>
{/* Shareable Link Input with Copy button */}
<div className="bg-rose-950/10 border border-rose-950/20 p-4 rounded-2xl space-y-2">
<div className="text-[10px] font-black text-rose-400 uppercase tracking-widest">Đưng dẫn khẩn cấp:</div>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${window.location.origin}/journey/${shareStatus.token}?lang=${lang}&theme=${theme}`}
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 text-xs text-slate-200 select-all outline-none"
/>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/journey/${shareStatus.token}?lang=${lang}&theme=${theme}`);
notify({ title: 'Thành công', message: 'Đã sao chép liên kết!', type: 'success' });
}}
className="bg-rose-600 hover:bg-rose-700 text-white font-bold px-4 py-2.5 rounded-xl text-xs transition-all active:scale-95 shrink-0"
>
{t('copyShareLink')}
</button>
</div>
</div>
</>
)}
</>
)}
</div>
{/* Modal Footer */}
<div className="p-6 bg-slate-950/20 border-t border-slate-800/80 flex justify-end">
<button
type="button"
onClick={() => setSharingTour(null)}
className="py-2.5 px-6 bg-slate-800 hover:bg-slate-700 text-white font-bold rounded-xl text-xs transition-all active:scale-95"
>
Đóng
</button>
</div>
</div>
</div>
)}
{/* Modal Footer */}
<div className="p-6 bg-slate-950/20 border-t border-slate-800/80 flex justify-end">
<button
type="button"
onClick={() => setSharingTour(null)}
className="py-2.5 px-6 bg-slate-800 hover:bg-slate-700 text-white font-bold rounded-xl text-xs transition-all active:scale-95"
>
Đóng
</button>
</div>
</div>
</div>
)}
</div>
</main>
+79 -2
View File
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Phone, MapPin, ShieldCheck } from 'lucide-react';
import { Capacitor } from '@capacitor/core';
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
interface SignupPageProps {
onBack: () => void;
@@ -69,8 +71,67 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
}
};
const handleNativeGoogleLogin = async () => {
setError('');
setIsLoading(true);
try {
const user = await GoogleAuth.signIn();
console.log('[SignupPage] Native Google user received:', user);
const idToken = user.authentication.idToken;
if (!idToken) {
throw new Error('Không nhận được token từ Google.');
}
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: idToken }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng ký bằng Google thất bại');
}
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
console.log('[SignupPage] Auto-joining tour with pending token after Google signup');
await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: pendingInviteToken }),
}).then(res => {
if (res.ok) {
localStorage.removeItem('pendingInviteToken');
console.log('[SignupPage] Successfully joined tour after Google signup');
} else {
console.warn('[SignupPage] Failed to join tour after Google signup:', res.status);
}
}).catch((e) => console.error('Lỗi tự động gia nhập:', e));
}
onSuccess();
} catch (err: any) {
console.error('[SignupPage] Native Google signup error:', err);
if (err?.message !== 'Sign in window was closed' && err?.message !== '12501') {
setError(err.message || 'Đăng ký bằng Google thất bại');
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (step !== 'form') return;
if (step !== 'form' || Capacitor.isNativePlatform()) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
@@ -313,7 +374,23 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
<span className="relative px-3 bg-[var(--surface)] text-xs font-bold text-[var(--text-muted)] uppercase">Hoặc</span>
</div>
<div id="google-signin-btn-signup" className="w-full flex justify-center"></div>
{Capacitor.isNativePlatform() ? (
<button
type="button"
onClick={handleNativeGoogleLogin}
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-slate-50 text-slate-800 font-bold py-3.5 px-4 rounded-2xl border border-slate-200 transition-all active:scale-[0.98] cursor-pointer text-sm shadow-sm"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="#EA4335"
d="M12.24 10.285V14.4h6.887c-.648 2.41-2.519 4.114-5.136 4.114-3.51 0-6.357-2.89-6.357-6.457 0-3.568 2.848-6.458 6.357-6.458 1.614 0 3.08.618 4.19 1.625l3.03-3.03C19.045 2.11 15.895 1 12.24 1 6.033 1 1 6.033 1 12.24s5.033 11.24 11.24 11.24c5.84 0 10.74-4.14 11.24-9.84v-3.355H12.24z"
/>
</svg>
Đăng bằng Google
</button>
) : (
<div id="google-signin-btn-signup" className="w-full flex justify-center"></div>
)}
</>
)}
</div>
+6 -3
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import { robotoBase64, robotoBoldBase64 } from '../utils/pdfFont';
@@ -1571,7 +1572,9 @@ export const TourDetailPage = ({
useEffect(() => {
if (!currentTour) return;
const socket = io(); // Kết nối qua Proxy của Vite (cùng origin)
const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
: io(); // Kết nối qua Proxy của Vite (cùng origin)
socket.on('connect', () => {
socket.emit('joinTour', currentTour.id);
@@ -1910,7 +1913,7 @@ export const TourDetailPage = ({
<div className="min-h-dvh bg-[var(--background)] pb-20 overflow-x-hidden">
{/* Top Navigation Bar */}
<div className="sticky top-0 z-30 bg-[var(--surface)]/80 backdrop-blur-md border-b border-[var(--border)] px-4 py-3 flex items-center justify-between">
<button onClick={onBack} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
<button onClick={activeTab === 'chat' ? () => setActiveTab('plan') : onBack} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
<ChevronLeft className="w-5 h-5 sm:w-6 sm:h-6 text-[var(--text-secondary)]" />
</button>
<h1 className="text-base sm:text-lg font-bold text-[var(--text-primary)] truncate px-4 flex-1 text-center">
@@ -2210,7 +2213,7 @@ export const TourDetailPage = ({
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full !px-0 !pb-0' : 'max-w-2xl mx-auto px-4 pb-24'}`}>
{/* Tab Switcher */}
{!(activeTab === 'plan' && viewMode === 'map') && (
{!(activeTab === 'plan' && viewMode === 'map') && activeTab !== 'chat' && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-[60px] z-40">
{tabs.map((tab) => (
<button
+84
View File
@@ -0,0 +1,84 @@
import { Capacitor } from '@capacitor/core';
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
import { LocalNotifications } from '@capacitor/local-notifications';
function rewriteUrls(obj: any, backendUrl: string): any {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
if (obj.startsWith('/uploads/')) {
return `${backendUrl}${obj}`;
}
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => rewriteUrls(item, backendUrl));
}
if (typeof obj === 'object') {
const newObj: any = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = rewriteUrls(obj[key], backendUrl);
}
}
return newObj;
}
return obj;
}
if (Capacitor.isNativePlatform()) {
// Initialize native Google Login client to prevent NullPointerException crashes
try {
GoogleAuth.initialize({
clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID || '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com',
scopes: ['profile', 'email'],
grantOfflineAccess: true,
});
console.log('[Native OAuth] GoogleAuth initialized successfully.');
} catch (e) {
console.error('[Native OAuth] Failed to initialize GoogleAuth client:', e);
}
// Request native local notification permission on startup
try {
LocalNotifications.requestPermissions().then(result => {
console.log('[LocalNotifications] Permissions requested:', result);
});
} catch (e) {
console.error('[LocalNotifications] Failed to request permissions:', e);
}
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn';
const originalFetch = window.fetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
let url = input;
if (typeof url === 'string') {
if (url.startsWith('/api/') || url.startsWith('/uploads/')) {
url = `${backendUrl}${url}`;
}
} else if (url instanceof URL) {
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/uploads/')) {
url = new URL(`${backendUrl}${url.pathname}${url.search}`);
}
} else if (url && typeof url === 'object' && 'url' in url) {
// If it is a Request object
const req = url as Request;
const reqUrl = req.url;
if (reqUrl.startsWith('/') || new URL(reqUrl).pathname.startsWith('/api/') || new URL(reqUrl).pathname.startsWith('/uploads/')) {
const targetUrl = reqUrl.startsWith('/') ? `${backendUrl}${reqUrl}` : reqUrl.replace(new URL(reqUrl).origin, backendUrl);
url = new Request(targetUrl, req);
}
}
const response = await originalFetch(url, init);
// Override the json method of this specific response instance
const originalJson = response.json;
response.json = async () => {
const data = await originalJson.call(response);
return rewriteUrls(data, backendUrl);
};
return response;
};
}
+27
View File
@@ -23,6 +23,33 @@ export default defineConfig(({ mode }) => {
'@': path.resolve(__dirname, './src'),
},
},
build: {
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('scheduler')) {
return 'vendor-react';
}
if (id.includes('leaflet')) {
return 'vendor-maps';
}
if (id.includes('jspdf')) {
return 'vendor-pdf';
}
if (id.includes('lucide-react')) {
return 'vendor-icons';
}
if (id.includes('quill')) {
return 'vendor-editor';
}
return 'vendor-others';
}
}
}
}
},
server: {
port: 3002,
host: true,
+184
View File
@@ -0,0 +1,184 @@
# Plan: Đăng nhập / Đăng ký bằng Tài khoản Google (Google OAuth)
Cho phép người dùng tạo tài khoản hoặc đăng nhập nhanh chóng bằng tài khoản Google đang được sử dụng trên thiết bị, không cần nhập email/mật khẩu thủ công.
---
## Luồng hoạt động
```
1. Người dùng nhấn nút "Đăng nhập bằng Google"
2. Frontend redirect → Backend endpoint /api/v1/auth/google
3. Backend redirect → Google OAuth Consent Screen
4. Google xác thực xong → callback về /api/v1/auth/google/callback
5. Backend tìm user theo googleId (hoặc email):
- Nếu đã tồn tại → đăng nhập, cấp JWT
- Nếu chưa có → tạo tài khoản mới, cấp JWT
6. Backend redirect về Frontend kèm JWT trong query param
7. Frontend đọc JWT → lưu localStorage → điều hướng vào app
```
---
## Open Questions
> [!IMPORTANT]
> Trước khi thực hiện, bạn cần:
> 1. Tạo **Google OAuth 2.0 Client** tại [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
> 2. Điền **Authorized redirect URI**: `http://localhost:3001/api/v1/auth/google/callback`
> 3. Cung cấp `GOOGLE_CLIENT_ID``GOOGLE_CLIENT_SECRET` để thêm vào `.env`
---
## Proposed Changes
### Backend
---
#### [MODIFY] [schema.prisma](file:///home/locpham/travelplanning/backend/prisma/schema.prisma)
- Thêm trường `googleId String? @unique` vào model `User` để lưu Google Account ID (unique identifier của từng tài khoản Google).
```diff
model User {
id String @id @default(uuid())
email String? @unique
+ googleId String? @unique
passwordHash String?
...
```
- Chạy migration: `npm run db:migrate`
---
#### [MODIFY] [main.ts](file:///home/locpham/travelplanning/backend/src/main.ts)
**1. Cài đặt thêm dependencies:**
```bash
npm install passport-google-oauth20 @types/passport-google-oauth20 -w backend
```
**2. Import & cấu hình Google Strategy (thêm vào đầu file):**
```typescript
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import * as passport from 'passport';
```
**3. Thêm 2 endpoint mới vào `AuthController`:**
- `GET /auth/google` — Khởi động OAuth flow, redirect sang Google
- `GET /auth/google/callback` — Google callback; tìm/tạo user, cấp JWT rồi redirect về frontend với token
```typescript
@Get('google')
async googleAuth(@Req() req: any, @Res() res: any) {
// Redirect đến Google login
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: `${process.env.BACKEND_URL}/api/v1/auth/google/callback`,
response_type: 'code',
scope: 'email profile',
access_type: 'offline',
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
}
@Get('google/callback')
async googleCallback(@Query('code') code: string, @Res() res: any) {
// 1. Exchange code for tokens với Google
// 2. Lấy profile (googleId, email, name, picture)
// 3. Upsert user theo googleId hoặc email
// 4. Tạo JWT
// 5. Redirect về frontend: http://localhost:5173/auth/callback?token=...&user=...
}
```
> [!NOTE]
> Không dùng `passport.authenticate()` middleware để đơn giản hóa, thay vào đó dùng `fetch` trực tiếp tới Google Token endpoint để trao đổi `code` lấy `access_token`, sau đó gọi Google People API để lấy profile.
**4. Thêm biến môi trường mới vào `.env`:**
```env
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
BACKEND_URL=http://localhost:3001
FRONTEND_URL=http://localhost:5173
```
---
### Frontend
---
#### [MODIFY] [App.tsx](file:///home/locpham/travelplanning/frontend/src/App.tsx)
- Thêm xử lý route `/auth/callback` (hoặc dùng `useEffect` kiểm tra query params khi app mount): đọc `?token=``?user=` từ URL, lưu vào `localStorage`, sau đó điều hướng vào trang explore.
---
#### [MODIFY] [LoginModal.tsx](file:///home/locpham/travelplanning/frontend/src/components/LoginModal.tsx)
- Thêm nút **"Đăng nhập bằng Google"** ở phía dưới form, tách biệt bằng divider `— hoặc —`.
- Khi nhấn: `window.location.href = '/api/v1/auth/google'`
```tsx
{/* Divider */}
<div className="flex items-center gap-3 my-4">
<div className="flex-1 border-t border-gray-200" />
<span className="text-xs text-gray-400 font-medium">hoặc</span>
<div className="flex-1 border-t border-gray-200" />
</div>
{/* Google Button */}
<button
type="button"
onClick={() => window.location.href = '/api/v1/auth/google'}
className="w-full flex items-center justify-center gap-3 bg-white border border-gray-200 hover:bg-gray-50 text-gray-700 font-semibold py-4 rounded-2xl shadow-sm transition-all"
>
<GoogleIcon />
Đăng nhập bằng Google
</button>
```
---
#### [MODIFY] [SignupPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/SignupPage.tsx)
- Thêm nút "Đăng ký nhanh bằng Google" tương tự ở phần `step === 'form'`.
- Cùng hành động: redirect sang `/api/v1/auth/google`.
---
## Sơ đồ kiến trúc
```mermaid
sequenceDiagram
participant U as Người dùng
participant FE as Frontend
participant BE as Backend
participant G as Google OAuth
U->>FE: Nhấn "Đăng nhập bằng Google"
FE->>BE: GET /api/v1/auth/google
BE->>G: Redirect → Google Consent Screen
G->>U: Hiển thị chọn tài khoản
U->>G: Chọn tài khoản
G->>BE: Callback với authorization code
BE->>G: Exchange code → access_token
BE->>G: GET profile (googleId, email, name, avatar)
BE->>BE: Upsert User (tìm theo googleId hoặc email)
BE->>BE: Tạo JWT
BE->>FE: Redirect về /auth/callback?token=...&user=...
FE->>FE: Lưu token/user vào localStorage
FE->>U: Điều hướng vào Explore Map
```
---
## Verification Plan
### Automated Tests
- `npm run build -w frontend` để kiểm tra TypeScript.
### Manual Verification
1. Nhấn "Đăng nhập bằng Google" → chọn tài khoản Google → kiểm tra được điều hướng vào app.
2. Đăng xuất → đăng nhập lại bằng cùng tài khoản Google → kiểm tra không bị tạo user mới.
3. Người dùng đã có tài khoản email trùng → kiểm tra được hợp nhất (merge) với tài khoản hiện có.
4. Kiểm tra `googleId` được lưu vào DB.
+78
View File
@@ -0,0 +1,78 @@
# Plan to Implement Like-Based Photo Selection, Displays, and Mobile UI Enhancements
Highlight photos with more likes across different displays and optimize the public photo modal's mobile UI layout.
---
## Proposed Changes
### 1. Public Photo Modal Mobile UI Layout & Fixes
#### [MODIFY] [PublicPhotoModal.tsx](file:///home/locpham/travelplanning/frontend/src/components/PublicPhotoModal.tsx)
- **Fixed Photo and Scrollable Comments**:
- Remove parent modal container vertical scrolling on mobile (`overflow-y-auto` -> `overflow-hidden`).
- Set the parent container layout to `flex flex-col h-[90vh] md:h-[85vh] overflow-hidden`.
- The photo container (`h-[45vh]`) and mobile uploader details container will remain static/fixed at the top.
- Set the comments panel container to `flex-1 min-h-0 flex flex-col` and the comments list to `flex-1 overflow-y-auto` so only the comments scroll on mobile.
- **Remove Comments Header on Mobile**:
- Add responsive classes to the comment header (`hidden md:block`) to hide the "Bình luận cộng đồng" / "bình luận từ cộng đồng" title banner on mobile, maximizing space.
- **Style Uploader Details**:
- Reduce font size and remove bold style for the "Người đăng" (Uploader name) elements.
- Desktop uploader text: change from `font-semibold` to `text-xs`.
- Mobile uploader text: change from `text-sm font-semibold text-white` to `text-xs text-slate-200`.
- **Safe Area Like Button**:
- Shift the absolute top position of the Like button to account for safe area insets: `top-[calc(1rem+env(safe-area-inset-top,0px))]`. This prevents browser address bars or notches from clipping it.
---
### 2. Landing Page Like-Based Sorting
#### [MODIFY] [LandingPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/LandingPage.tsx)
- In `fetchPublicPhotos`, sort the fetched `publicPhotos` array based on the number of likes (`likedUserIds.length`) in descending order.
- This ensures:
1. The cycling background images naturally present the most-liked public photos first.
2. The gallery previews at the bottom display the top 8 most-liked photos from the community.
---
### 3. Tour Banner Image Selection
#### [MODIFY] [TourDetailPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/TourDetailPage.tsx)
- Replace the simple first-photo check for `coverImage` with a function that retrieves the photo with the most likes in the tour.
- If there are no photos in the tour, fall back to the default Unsplash placeholder cover image.
---
### 4. Auto-Select Most Liked Photo for Display
#### [MODIFY] [ExploreMap.tsx](file:///home/locpham/travelplanning/frontend/src/pages/ExploreMap.tsx)
- In `groupedPhotos` memo, sort each location's photo array so that photos with the most likes appear first.
- This ensures the map pin thumbnail bubble displays the most-liked photo at that coordinate, and clicking it displays that photo as the default.
#### [MODIFY] [MyPhotosPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/MyPhotosPage.tsx)
- Define a helper function `getMostLikedPhoto(photos)` that returns the photo with the most likes.
- In the `useEffect` that updates `selectedPhotoForDisplay`, use this helper to default-select the most liked photo from the filtered list.
#### [MODIFY] [TourDetailPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/TourDetailPage.tsx)
- Define the same helper function `getMostLikedPhoto(photos)`.
- Update the default photo selection inside both `useEffect` hooks (the one listening to `filteredPhotos` and the one listening to `currentTour`/`legs` changes) to select the photo with the most likes using this helper.
---
## Verification Plan
### Automated Tests
- Run `npm run build -w frontend` to verify that there are no compilation or syntax errors.
### Manual Verification
- **Mobile Public Photo Modal**:
1. View a photo on mobile browser/emulator.
2. Verify only the comments scroll, and the header label is hidden.
3. Verify the Like button position is adjusted.
4. Verify the Uploader text size is reduced and normal.
- **Landing Page background & gallery**:
1. Open the landing page. Verify that the previews show the most liked photos first.
- **Tour Banner**:
1. Verify the tour banner updates to show the photo with the most likes.
- **Map Marker / Photo Timeline default**:
1. Click map marker and verify most liked photo is active by default.
+166
View File
@@ -0,0 +1,166 @@
# Kế hoạch triển khai: 11 Tính năng mới (Bổ sung Đa ngôn ngữ & Giao diện Sáng/Tối)
Kế hoạch này phác thảo các thay đổi về cơ sở dữ liệu (Prisma Schema), các API Endpoint ở Backend (NestJS), các Component và trang ở Frontend (React) để hiện thực hóa 11 tính năng mới theo yêu cầu của bạn.
---
## User Review Required
> [!IMPORTANT]
> - **Hệ thống đa ngôn ngữ (Tiếng Việt, Tiếng Anh, Tiếng Trung)**: Chúng tôi sẽ triển khai một hook dịch thuật tinh gọn `useTranslation` dạng từ điển lưu trữ cục bộ ở Frontend. Toàn bộ các tiêu đề, nhãn (labels), nút bấm, thông báo toast và văn bản tĩnh trong dự án sẽ được ánh xạ qua hook này dựa trên ngôn ngữ được lựa chọn. Lựa chọn ngôn ngữ sẽ được lưu trữ vào `localStorage` để giữ trạng thái sau khi tải lại trang.
> - **Cơ chế Giao diện Sáng / Tối (Light / Dark Theme)**: Hệ thống sẽ hỗ trợ 3 tùy chọn: Sáng (Light), Tối (Dark), và theo Hệ thống (System default, sử dụng `prefers-color-scheme` để tự động đổi màu theo hệ điều hành). Chúng tôi sẽ tích hợp các class tiện ích Tailwind CSS v4 (như `dark:bg-slate-900`, `dark:text-slate-100`, `dark:border-slate-800`) vào tất cả các component chính để giao diện đồng bộ hoàn hảo.
> - **Cơ chế lọc ảnh khiêu dâm & Làm mờ khuôn mặt**: Xử lý trực tiếp trên trình duyệt (Client-side) qua TensorFlow.js trước khi tải lên để đảm bảo tối ưu hiệu năng backend.
> - **Tài khoản Admin quản trị hệ thống bằng Secret Key**: Đọc `ADMIN_SECRET_KEY` từ `.env`. Ở Frontend, người dùng có thể kích hoạt quyền Admin tức thời bằng cách nhập chuỗi khóa này để được promote lên `isAdmin: true` trong Database.
---
## Open Questions
> [!WARNING]
> - Bạn có muốn lưu tùy chọn ngôn ngữ trực tiếp vào tài khoản User ở Database (để khi đăng nhập trên thiết bị khác thì tự động áp dụng ngôn ngữ đó) hay chỉ cần lưu ở `localStorage` trình duyệt là đủ?
> - Ngoài các ngôn ngữ Tiếng Việt, Tiếng Anh, Tiếng Trung, bạn có dự định mở rộng thêm ngôn ngữ nào khác trong tương lai không?
---
## Proposed Changes
### 1. Thay đổi Cơ sở dữ liệu (`backend/prisma/schema.prisma`)
Chúng ta sẽ bổ sung các Model mới để lưu trữ: Bộ lọc từ khóa, Cấu hình bộ lọc hình ảnh, Đánh giá Owner, và Mã chia sẻ hành trình.
#### [MODIFY] [schema.prisma](file:///home/locpham/travelplanning/backend/prisma/schema.prisma)
- Thêm model `WordFilter` để cấu hình bộ lọc từ khóa cấm.
- Thêm model `ModerationSetting` để lưu trạng thái cấu hình lọc của hệ thống.
- Thêm model `TourRating` để lưu đánh giá 5 sao cho Owner/Manager từ các thành viên.
- Thêm model `TourShare` để quản lý liên kết chia sẻ hành trình khẩn cấp.
```prisma
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)
}
model TourRating {
id String @id @default(uuid())
tourId String
targetUserId String // Owner/Manager được đánh giá
raterUserId String // Người dùng thực hiện đánh giá
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)
}
```
---
### 2. Backend API (`backend/src/main.ts`)
#### [MODIFY] [main.ts](file:///home/locpham/travelplanning/backend/src/main.ts)
- Triển khai đầy đủ các endpoint như trong kế hoạch trước đó:
- `DELETE /api/v1/public-photos/comments/:id`
- Tích hợp bộ lọc từ khóa cấm ở các API tạo bài đăng/tin nhắn/bình luận.
- API cài đặt bộ lọc ảnh và text của Admin (`/api/v1/admin/moderation`, `/api/v1/admin/word-filters`).
- API Đánh giá Owner/Manager (`/api/v1/tours/:tourId/ratings`) và Leaderboard uy tín (`/api/v1/users/trusted`).
- API kích hoạt Admin bằng secret key (`/api/v1/auth/promote-admin`).
- API chia sẻ hành trình khẩn cấp (`/api/v1/tours/:tourId/share`, `/api/v1/tours/share/:token`).
---
### 3. Giao diện Frontend (`frontend/src/...`)
#### [NEW] [useTranslation.ts](file:///home/locpham/travelplanning/frontend/src/hooks/useTranslation.ts)
- Tạo hook `useTranslation` để quản lý đa ngôn ngữ:
- Khai báo từ điển ngôn ngữ `translations` chứa các bản dịch Tiếng Việt (VI), Tiếng Anh (EN), Tiếng Trung (ZH).
- Hỗ trợ hàm dịch `t(key: string)` và các hàm chuyển đổi ngôn ngữ, tự động đồng bộ giá trị với `localStorage.getItem('language')`.
- Dịch các tiêu đề, thông báo, nhãn, nút bấm trong toàn bộ ứng dụng sang 3 ngôn ngữ tương ứng.
#### [NEW] [useTheme.ts](file:///home/locpham/travelplanning/frontend/src/hooks/useTheme.ts)
- Tạo hook `useTheme` để quản lý giao diện Sáng/Tối:
- Quản lý trạng thái theme (`light`, `dark`, `system`).
- Nếu chọn `system`, sử dụng `window.matchMedia('(prefers-color-scheme: dark)')` để lắng nghe thiết lập hệ điều hành của người dùng.
- Thêm hoặc xóa class `dark` khỏi thẻ `<html>` hoặc `<body>` của ứng dụng.
#### [MODIFY] [index.css](file:///home/locpham/travelplanning/frontend/src/index.css)
- Bổ sung cấu hình màu sắc cơ bản cho dark mode: định nghĩa các màu nền và chữ chính cho chế độ tối để đảm bảo độ tương phản tốt nhất và đồng bộ thiết kế.
#### [MODIFY] [ExploreMap.tsx](file:///home/locpham/travelplanning/frontend/src/pages/ExploreMap.tsx), [MemberDashboard.tsx](file:///home/locpham/travelplanning/frontend/src/pages/MemberDashboard.tsx), [LandingPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/LandingPage.tsx)
- **Tích hợp Dark Mode**: Thêm các lớp CSS Tailwind như `dark:bg-slate-900`, `dark:text-slate-100`, `dark:border-slate-800` vào tất cả các card, danh sách và thanh điều hướng chính.
- **Thanh công cụ**: Thêm dropdown lựa chọn ngôn ngữ (Tiếng Việt, Tiếng Anh, Tiếng Trung) và nút chuyển đổi giao diện (Sáng/Tối/Hệ thống) ở Sidebar/Header cạnh nút Đăng xuất hoặc trong Menu Cài đặt của Admin.
- **Áp dụng Đa ngôn ngữ**: Thay thế toàn bộ text hiển thị tĩnh bằng hàm dịch `t('key')` (ví dụ: `t('explore')`, `t('logout')`, `t('trusted_members')`,...).
#### [MODIFY] [ItineraryTimeline.tsx](file:///home/locpham/travelplanning/frontend/src/components/ItineraryTimeline.tsx)
- Bổ sung click vào địa điểm để xem trên bản đồ (gọi `onNavigate`).
- Áp dụng các lớp Tailwind chế độ tối (`dark:bg-slate-900`, `dark:border-slate-800`, `dark:text-gray-100`) để các thẻ chặng và chi tiết điểm dừng hiển thị hoàn hảo ở Dark Mode.
- Thay các đoạn text tĩnh bằng đa ngôn ngữ.
#### [MODIFY] [TourDetailPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/TourDetailPage.tsx)
- Thêm nút "Xuất PDF" (không kèm chi phí) sử dụng `html2pdf.js` từ CDN.
- Tích hợp Đánh giá ban tổ chức 5 sao.
- Tích hợp Đa ngôn ngữ và Dark Mode vào toàn bộ trang chi tiết hành trình.
#### [MODIFY] [PublicPhotoModal.tsx](file:///home/locpham/travelplanning/frontend/src/components/PublicPhotoModal.tsx)
- Cho phép xóa bình luận nếu thỏa mãn điều kiện quyền hạn.
- Tích hợp Đa ngôn ngữ và Dark Mode.
#### [MODIFY] [CoordinateSelectModal.tsx](file:///home/locpham/travelplanning/frontend/src/components/CoordinateSelectModal.tsx)
- Ẩn liên kết Leaflet bằng `attributionControl={false}`.
- Bổ sung ô tìm kiếm địa điểm tích hợp Nominatim API.
- Tích hợp Dark Mode (bảng kết quả tìm kiếm và các khung modal chuyển sang màu tối phù hợp).
#### [NEW] [ShareJourneyPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/ShareJourneyPage.tsx)
- Giao diện chia sẻ khẩn cấp, tích hợp Đa ngôn ngữ và hỗ trợ cả 2 chế độ Sáng / Tối.
#### [MODIFY] [App.tsx](file:///home/locpham/travelplanning/frontend/src/App.tsx)
- Cập nhật logic route `/journey/:token`.
- Bao bọc ứng dụng để khởi chạy ngôn ngữ và theme mặc định dựa trên cấu hình hệ thống / localStorage khi ứng dụng khởi chạy.
---
## Verification Plan
### Automated Tests
- Chạy biên dịch toàn bộ hệ thống để phát hiện lỗi TypeScript:
- Backend: `npm run build -w backend`
- Frontend: `npm run build -w frontend` hoặc chạy bộ kiểm tra TypeScript: `npx tsc --noEmit` ở thư mục frontend.
### Manual Verification
1. **Kiểm tra Đa ngôn ngữ**:
- Chuyển đổi ngôn ngữ sang Tiếng Anh / Tiếng Trung.
- Xác nhận tất cả tiêu đề, nhãn, nút bấm, thông báo toast cập nhật sang ngôn ngữ mới.
2. **Kiểm tra Dark Mode**:
- Thay đổi cài đặt sang "Tối". Xác nhận toàn bộ giao diện (kể cả các card trắng ở dashboard, timeline, map modal) chuyển thành màu tối.
- Thay đổi cài đặt sang "Hệ thống". Đổi cấu hình hệ điều hành từ Light sang Dark và ngược lại để xác nhận trang web đổi giao diện tương ứng theo thời gian thực.
3. **Kiểm tra click địa điểm xem bản đồ**: Xác nhận click vào địa điểm timeline tự động nhảy sang tab bản đồ và hiển thị lộ trình.
4. **Kiểm tra xuất PDF**: Xác nhận xuất lịch trình ra file PDF không chứa thông tin chi phí.
5. **Kiểm tra xóa bình luận công khai, lọc ảnh & text, chia sẻ khẩn cấp, admin secret key**.
+3504 -65
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
# Nhiệm vụ: Sửa giao diện Bong bóng Tour & Kết nối Thành viên
- [x] [Database] Thêm model `UserConnection``DirectMessage` vào schema.prisma và chạy db:migrate
- [x] [Backend] Tạo các API quản lý kết nối (`connections`) và tin nhắn (`messages`) cùng WebSocket Gateway
- [x] [Frontend] Sửa luồng redirect sau khi đăng nhập/đăng ký bằng Google trong App.tsx
- [x] [Frontend] Sửa vị trí tâm neo bong bóng Tour (iconSize & iconAnchor) trong ExploreMap.tsx
- [x] [Frontend] Thêm màu viền (xanh, đỏ, trắng) và tooltip phân loại Tour trên bản đồ khám phá
- [x] [Frontend] Chặn click trái đối với tour viền xanh; cài đặt touchstart/contextmenu để xin join bằng nhấn giữ/click phải
- [x] [Frontend] Xây dựng trang Dashboard thành viên (`MemberDashboard.tsx`) tích hợp chat box, quản lý kết nối bạn bè/gia đình, quản lý tour và ảnh cá nhân
## Giai đoạn 2: Sửa đổi giao diện Dashboard Thành viên (Desktop & Mobile)
- [x] [Frontend] Cập nhật giao diện MyPhotosPage.tsx thành Dark Slate theme để đồng bộ với dashboard
- [x] [Frontend] Tích hợp trực tiếp MyPhotosPage vào tab Thư viện ảnh trong MemberDashboard.tsx
- [x] [Frontend] Sửa giao diện Desktop của MemberDashboard.tsx: căn giữa h-[90vh], chỉnh màu thẻ tour, và sửa đè viền Mạng lưới kết nối
- [x] [Frontend] Sửa giao diện Mobile của MemberDashboard.tsx: thiết lập hiển thị fullscreen menu các nút, và quay lại menu từ các trang chi tiết/chat
- [x] Xác minh toàn bộ tính năng và lỗi hiển thị trên cả desktop và điện thoại di động
## Giai đoạn 3: Realtime Notification qua WebSockets
- [x] [Backend] Thêm notifyConnectionAccepted và notifyJoinRequestAccepted vào CommentGateway trong main.ts
- [x] [Backend] Inject CommentGateway và trigger notifyConnectionAccepted trong ConnectionController
- [x] [Backend] Inject CommentGateway và trigger notifyJoinRequestAccepted trong TourController
- [x] [Frontend] Thêm event listeners (connectionAccepted, joinRequestAccepted) vào socket hook trong MemberDashboard.tsx
- [x] Xác minh các sự kiện thông báo thời gian thực trên giao diện người dùng
## Giai đoạn 4: Notification Badges & System Push Notifications (Mobile/Desktop)
- [x] [Frontend] Khởi tạo yêu cầu quyền Notification hệ thống khi mount và tạo helper showSystemNotification
- [x] [Frontend] Khai báo các state unreadChatSenders và unreadTourSenders cùng helper isMemberOfMyTours
- [x] [Frontend] Cập nhật event listener messageReceived để kích hoạt push notification và set các state chưa đọc
- [x] [Frontend] Hiển thị Badge thông báo trên các tab/menu button và danh sách chat liên hệ
- [x] [Frontend] Xử lý xóa trạng thái chưa đọc khi chọn chat user hoặc click tab Hành trình của tôi
- [x] Xác minh hiển thị Badge và thông báo đẩy hệ thống thành công trên cả desktop/mobile
## Giai đoạn 5: Trò chuyện Nhóm Hành trình & Đính kèm (Ảnh nén 2K + Vị trí GPS)
- [x] [Database] Cập nhật schema.prisma (thêm TourMessage và các trường DirectMessage), chạy db migration
- [x] [Backend] Thêm UploadController, cập nhật DirectMessageController và tạo TourMessageController trong main.ts
- [x] [Frontend] Viết component TourChat.tsx (nén canvas 2K, tải ảnh, lấy vị trí GPS)
- [x] [Frontend] Tích hợp tab Trò chuyện và sự kiện socket vào TourDetailPage.tsx
- [x] [Frontend] Nâng cấp giao diện chat trong MemberDashboard.tsx (nén canvas 2K, tải ảnh, GPS, và badge tour chat)
- [x] Xác minh biên dịch TypeScript và chạy các kịch bản kiểm thử trò chuyện trực tiếp/nhóm thành công
## Giai đoạn 6: Nhắc tên (@Mention) trong Chat & Tắt thông báo
- [x] [Frontend] Sửa lỗi hiển thị bong bóng tour trên bản đồ bằng cách quét tất cả chặng (ExploreMap.tsx)
- [x] [Backend] Cập nhật API gửi tin nhắn nhóm tour để nhận taggedUserIds và gửi socket notification riêng (main.ts)
- [x] [Frontend] Thêm chức năng tag @ thành viên trong component TourChat.tsx
- [x] [Frontend] Thêm nút Bật/Tắt thông báo đẩy & logic Mute trong MemberDashboard.tsx
- [x] Xác minh biên dịch TypeScript và chạy các kịch bản kiểm thử tag & mute thành công
+185
View File
@@ -0,0 +1,185 @@
# Kế hoạch: Tái thiết kế giao diện xem ảnh và bình luận trên Mobile (PublicPhotoModal)
Thiết kế lại giao diện mobile của modal xem ảnh công khai để trải nghiệm cuộn trang tự nhiên hơn, tương tự như Instagram/Twitter — ảnh chiếm toàn bộ chiều rộng màn hình từ trên xuống, thông tin người đăng và các nút được bố cục rõ ràng, sau đó cuộn xuống xem bình luận một cách liền mạch.
---
## Phân tích hiện trạng
Cấu trúc hiện tại của modal trên mobile (`flex flex-col`):
1. **Container (90vh, overflow-hidden)** — toàn bộ nội dung bị "kẹt" trong 90vh
2. Phần ảnh (`h-[45vh]`) — cố định, không thể co giãn
3. Phần "Mobile Uploader" strip — cố định (`shrink-0`)
4. Phần comment panel (`flex-1 min-h-0`) — chỉ phần danh sách comment cuộn bên trong
**Vấn đề**: Ảnh bị giới hạn ở `h-[45vh]`, có padding `p-4` từ container ngoài nên không chạm mép màn hình. Nút like nằm ở góc trên trái (gần nút X), người đăng nằm ở vùng riêng giữa ảnh và comment, không cuộn cùng content.
---
## Thay đổi đề xuất
### [MODIFY] [PublicPhotoModal.tsx](file:///home/locpham/travelplanning/frontend/src/components/PublicPhotoModal.tsx)
#### 1. Outer wrapper — Bỏ padding và căn giữa
**Hiện tại** (line 358):
```
<div className="fixed inset-0 z-[5000] flex items-center justify-center p-4">
```
**Thay thành** — trên mobile modal full screen, không padding, không căn giữa. Desktop giữ nguyên layout cũ:
```
<div className="fixed inset-0 z-[5000] md:flex md:items-center md:justify-center md:p-4">
```
#### 2. Container modal — Mobile: full screen cuộn tự nhiên; Desktop: giữ nguyên
**Hiện tại** (line 366):
```
<div className="relative w-full max-w-5xl h-[90vh] md:h-[85vh] bg-slate-900 ... overflow-hidden flex flex-col md:flex-row ...">
```
**Thay thành**:
- Mobile: `fixed inset-0 overflow-y-auto flex flex-col bg-slate-900` — toàn màn hình, cuộn tự nhiên theo trang
- Desktop: `relative w-full max-w-5xl h-[85vh] overflow-hidden flex flex-row rounded-[32px]`
```
<div className="
fixed inset-0 overflow-y-auto flex flex-col bg-slate-900 text-slate-100
md:relative md:inset-auto md:overflow-hidden md:rounded-[32px] md:shadow-2xl md:border md:border-slate-800
md:w-full md:max-w-5xl md:h-[85vh] md:flex-row
animate-in zoom-in-95 duration-300
">
```
#### 3. Nút X — Fixed ở góc trên phải (hiện tại đúng, cần giữ nguyên)
Nút X hiện đang dùng `fixed`**giữ nguyên**, chỉ điều chỉnh `top` để tính safe-area:
```
className="fixed top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 z-50 ..."
```
#### 4. Phần ảnh (Left Side Photo) — Mobile: full width, không giới hạn chiều cao, aspect ratio tự nhiên
**Hiện tại** (line 377):
```
<div className="relative w-full md:w-3/5 h-[45vh] md:h-full ... shrink-0">
```
**Thay thành** — Trên mobile: ảnh full width, chiều cao theo tỷ lệ ảnh (aspect-ratio), không giới hạn, chạm mép trên màn hình (không có padding trên):
```
<div className="relative w-full md:w-3/5 md:h-full bg-slate-950 flex items-center justify-center overflow-hidden group shrink-0">
```
Với ảnh bên trong:
```
<img className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none cursor-zoom-in" />
```
#### 5. Nút Like — Di chuyển từ góc trên trái sang góc dưới phải của vùng ảnh
**Hiện tại**: `absolute top-[...] left-4` — góc trên bên trái.
**Thay thành**: `absolute bottom-4 right-4` — góc dưới bên phải của ảnh, không bị che bởi thanh địa chỉ và không xung đột với nút X:
```
className="absolute bottom-4 right-4 z-40 ..."
```
#### 6. Thông tin người đăng — Di chuyển thành overlay góc trên trái trên ảnh
Hiện tại "Mobile Uploader strip" là một block riêng biệt giữa ảnh và comment (`block md:hidden p-6 ...`).
**Thay thành**: Overlay trực tiếp trên ảnh ở góc **trên bên trái**, nằm trong vùng ảnh, có safe-area padding:
```html
<!-- Bên trong div ảnh, sau img tag -->
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-4 z-40 md:hidden
flex items-center gap-2 bg-slate-950/70 backdrop-blur-md px-2.5 py-1.5 rounded-full border border-slate-700/50">
<div className="w-6 h-6 rounded-full bg-emerald-500/20 flex items-center justify-center">
<User className="w-3.5 h-3.5 text-emerald-400" />
</div>
<span className="text-xs text-slate-200 max-w-[120px] truncate">{photo.uploader?.name || 'Ẩn danh'}</span>
</div>
```
#### 7. "Mobile Uploader strip" block — Xóa block cũ
Block `{/* Mobile Only: Uploader details & divider */}` ở lines 597623 sẽ được **xóa** vì thông tin người đăng đã được hiển thị trực tiếp trên ảnh ở bước 6.
Nếu `isAuthorized && photo.originalUrl` (nút Tải ảnh gốc): sẽ được giữ lại nhưng chuyển vào trong phần metadata/overlay của ảnh hoặc đặt trước phần comments.
#### 8. Phần Comments — Cuộn cùng với nội dung (không bị kẹt trong flex)
Trên mobile, toàn bộ trang cuộn — phần comment không cần `overflow-y-auto` riêng nữa (vì parent đã cuộn):
**Hiện tại** (line 626):
```
<div className="w-full md:w-2/5 flex-1 min-h-0 flex flex-col ...">
```
**Thay thành**:
```
<div className="w-full md:w-2/5 md:flex-1 md:min-h-0 flex flex-col ...">
```
Danh sách bình luận (line 640):
```
<div className="flex-1 overflow-y-auto p-6 space-y-4 ...">
```
→ Trên mobile không giới hạn `overflow-y-auto`:
```
<div className="md:flex-1 md:overflow-y-auto p-6 space-y-4 ...">
```
#### 9. Comment Input — Sticky ở cuối trang trên mobile
Thay vì nằm cố định cuối flex column, ô nhập bình luận sẽ sticky ở bottom:
```
<div className="sticky bottom-0 md:static p-4 bg-slate-950/95 md:bg-slate-950/40 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4">
```
---
## Tóm tắt layout cuối cùng trên mobile
```
┌─────────────────────────────────────────────┐ ← màn hình (fixed inset-0)
│ [X nút đóng] (góc trên phải, fixed/z-50)
│─────────────────────────────────────────────│
│ │
│ [Người đăng] (overlay TL) │
│ │ ← Vùng ảnh (full width,
│ ẢNH │ height theo tỷ lệ)
│ │
│ [❤ Like + số] │
│─────────────────────────────────────────────│
│ 📷 Timeline ảnh (nếu có nhiều ảnh) │ ← Cuộn cùng trang
│─────────────────────────────────────────────│
│ Tiêu đề, mô tả, ngày chụp, địa điểm │ ← Info overlay panel
│─────────────────────────────────────────────│
│ [Tải ảnh gốc] (nếu là chủ sở hữu) │
│─────────────────────────────────────────────│
│ 💬 Comment 1 │ ← Cuộn cùng trang
│ 💬 Comment 2 │
│ 💬 Comment 3 │
│ ... │
│─────────────────────────────────────────────│
│ [Viết bình luận...] [▶ Gửi] (sticky bottom)│
└─────────────────────────────────────────────┘
```
---
## Verification Plan
### Automated Tests
- `npm run build -w frontend` — không có lỗi compilation.
### Manual Verification
1. Mở app trên Chrome mobile (Android) → modal mở full screen, ảnh chạm top, không có padding.
2. Nút X hiển thị góc trên phải, luôn visible.
3. Tên người đăng hiển thị overlay góc trên trái, không nhầm với người bình luận.
4. Nút like hiển thị góc dưới phải của ảnh.
5. Vuốt lên → ảnh và bình luận cuộn cùng nhau một cách liền mạch.
6. Ô bình luận sticky ở dưới cùng.
7. Trên desktop (≥768px) layout 2 cột giữ nguyên hoàn toàn.
8. Safe area insets hoạt động đúng trên iPhone (notch/Dynamic Island) dùng Safari.
+171
View File
@@ -0,0 +1,171 @@
# Kế hoạch triển khai: Tính năng nâng cấp & Sửa lỗi hệ thống YoTrip
Bản kế hoạch này phác thảo chi tiết phương án kỹ thuật để thực hiện các tính năng mới và khắc phục các lỗi tồn tại trong dự án theo yêu cầu.
---
## User Review Required
> [!IMPORTANT]
> - **Cập nhật database schema (Prisma Migrations)**: Chúng ta cần thêm bảng mới `TourNote``RecommendedLocation`, đồng thời bổ sung các trường `isDeleted``deletedAt` cho bảng `Tour``Photo` để phục vụ tính năng Thùng rác (Trash Bin). Việc này yêu cầu chạy prisma migrations (`npx prisma migrate dev`).
> - **Thay đổi Nginx config**: Cấu hình Nginx trong Docker production cần bổ sung `client_max_body_size 50M;` để hỗ trợ tải ảnh lớn lên proxy trước khi nén trên client, tránh lỗi HTTP 413.
> - **Cơ chế Hybrid Lưu trữ Ghi chú (Notes)**: Ghi chú sẽ được lưu trữ song song (hybrid) cả ở `localStorage` (để hiển thị tức thời và hoạt động offline) lẫn đồng bộ lên server (để lưu trữ lâu dài và cho phép Admin kiểm duyệt/moderation). Khi mở trang ghi chú, ứng dụng sẽ tải dữ liệu từ cache `localStorage` trước, sau đó fetch dữ liệu mới nhất từ server để đồng bộ và cập nhật lại cache.
---
## Proposed Changes
### 1. Cơ sở dữ liệu (Database Schema)
#### [MODIFY] [schema.prisma](file:///home/locpham/travelplanning/backend/prisma/schema.prisma)
- **Bảng `Photo` & `Tour`**: Bổ sung hai trường `isDeleted Boolean @default(false)``deletedAt DateTime?` để hỗ trợ cơ chế soft-delete (chuyển vào thùng rác).
- **Bảng `ModerationSetting`**: Thêm trường `trashRetentionDays Int @default(30)` để lưu trữ số ngày lưu trữ trong thùng rác do admin quy định.
- **Thêm bảng `TourNote`**: Lưu trữ ghi chú của Tour liên kết với `Tour``User` sở hữu:
```prisma
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?
}
```
- **Thêm bảng `RecommendedLocation`**: Lưu trữ các địa điểm đề xuất:
```prisma
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) // Số sao đánh giá từ 1 đến 5
isApproved Boolean @default(false)
createdAt DateTime @default(now())
}
```
---
### 2. Backend API (`backend/src/main.ts`)
- **Soft-Delete cho Tours, Photos, và Notes**:
- Cập nhật hàm `deleteTour`, `deletePhoto` để chỉ set `isDeleted: true``deletedAt: new Date()` thay vì xóa vĩnh viễn.
- Cập nhật tất cả các truy vấn tour, ảnh (public & private) để loại trừ các bản ghi có `isDeleted: true`.
- **TourNote API**:
- `GET /api/v1/tours/:tourId/notes`: Lấy danh sách ghi chú của một tour (phải là thành viên tour hoặc admin).
- `POST /api/v1/tours/:tourId/notes`: Tạo ghi chú cho tour.
- `PUT /api/v1/tours/:tourId/notes/:noteId`: Sửa ghi chú.
- `DELETE /api/v1/tours/:tourId/notes/:noteId`: Soft-delete ghi chú của tour.
- **RecommendedLocation API**:
- `GET /api/v1/recommendations`: Lấy danh sách địa điểm đề xuất đã duyệt (`isApproved: true`).
- `POST /api/v1/recommendations`: Người dùng gửi đề xuất địa điểm mới.
- `GET /api/v1/admin/recommendations`: Admin xem toàn bộ danh sách đề xuất.
- `PATCH /api/v1/admin/recommendations/:id/approve`: Admin phê duyệt địa điểm đề xuất.
- `DELETE /api/v1/admin/recommendations/:id`: Admin xóa đề xuất.
- **Admin Trash Bin API**:
- `GET /api/v1/admin/trash`: Lấy danh sách các đối tượng trong thùng rác (phân loại Tours, Photos, Notes) kèm thông tin ngày xóa.
- `POST /api/v1/admin/trash/restore`: Phục hồi các đối tượng được chọn (`isDeleted: false`).
- `POST /api/v1/admin/trash/delete-permanent`: Xóa vĩnh viễn các đối tượng được chọn khỏi ổ đĩa và DB.
- `PATCH /api/v1/admin/trash/retention-days`: Cập nhật cấu hình `trashRetentionDays`.
- **Cronjob dọn dẹp tự động (Background Service)**:
- Khởi tạo một task chạy định kỳ (24 giờ một lần) quét qua các bảng `Tour`, `Photo`, `TourNote``isDeleted: true``deletedAt` đã quá hạn so với cấu hình `trashRetentionDays` để tự động xóa vĩnh viễn bản ghi và tệp vật lý.
---
### 3. Frontend & UI
#### [MODIFY] [nginx.conf](file:///home/locpham/travelplanning/frontend/nginx.conf)
- Thêm dòng `client_max_body_size 50M;` vào cấu hình block `server` của Nginx để khắc phục triệt để lỗi `413 (Request Entity Too Large)`.
#### [NEW] [image.ts](file:///home/locpham/travelplanning/frontend/src/utils/image.ts)
- Viết hàm tiện ích nén ảnh trên Client sử dụng Canvas API, giảm kích thước ảnh về chiều dài nhất tối đa **2048px (2K)**, chuyển định dạng sang `image/jpeg` chất lượng `0.85` trước khi tải lên.
- Áp dụng hàm nén này cho:
- Chụp ảnh/Tải ảnh ẩn danh ở [LandingPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/LandingPage.tsx)
- Tải ảnh lên trong Tour ở [AddPhotoModal.tsx](file:///home/locpham/travelplanning/frontend/src/components/AddPhotoModal.tsx)
- Các vị trí upload ảnh khác.
#### [MODIFY] [LoginModal.tsx](file:///home/locpham/travelplanning/frontend/src/components/LoginModal.tsx)
- Thêm class CSS `max-h-[90vh] overflow-y-auto` vào container chính của modal để tránh bị che mất nút đăng nhập trên các trình duyệt di động có chiều cao màn hình nhỏ hoặc khi bàn phím ảo hiển thị.
#### [MODIFY] [TourDetailPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/TourDetailPage.tsx)
- **Khắc phục lỗi xuất PDF (`oklab` error)**:
- Thay vì clone trực tiếp DOM timeline chứa màu oklab phức tạp của Tailwind v4, chúng ta sẽ xây dựng trực tiếp một phần tử HTML `div` đơn giản, dùng cấu trúc bảng hoặc danh sách căn lề cơ bản, sử dụng màu HEX tiêu chuẩn (`#ffffff`, `#3b82f6`, v.v.).
- Trước khi gọi `html2pdf().from(element).save()`, tạm thời đặt `.disabled = true` cho tất cả các thẻ `<style>``<link rel="stylesheet">` trong tài liệu (ngoại trừ CSS tuỳ chỉnh cho PDF), sau đó phục hồi lại trạng thái cũ trong block `finally` của `try-catch`. Phương án này giúp `html2canvas` bỏ qua hoàn toàn các stylesheet chứa hàm màu `oklab` của Tailwind.
- **Xuất Google Sheets**:
- Bổ sung nút "Xuất CSV (Google Sheets)".
- Khi nhấn, tạo chuỗi nội dung CSV chi tiết lịch trình gồm: Chặng, Địa điểm, Thời gian bắt đầu, Kết thúc, Thời gian dừng, Ghi chú.
- Ghép tiền tố UTF-8 BOM (`\uFEFF`) để hỗ trợ tốt font Tiếng Việt có dấu khi import trực tiếp vào Google Sheets / Excel, và kích hoạt download tệp dạng `.csv`.
#### [MODIFY] [MyNotePage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/MyNotePage.tsx)
- **Tách biệt ghi chú theo Tour**: Nhận prop `tourId` từ component cha và thực hiện gọi các API `/api/v1/tours/:tourId/notes` để truy xuất và lưu trữ ghi chú trực tiếp lên máy chủ, giải quyết việc các ghi chú hiển thị chung.
#### [MODIFY] [LandingPage.tsx](file:///home/locpham/travelplanning/frontend/src/pages/LandingPage.tsx)
- **Lướt ảnh bằng tay (Swiping Carousel)**:
- Thay thế hệ thống crossfade tĩnh bằng một wrapper flex container xếp ngang toàn bộ ảnh cộng đồng.
- Sử dụng các sự kiện `onTouchStart`, `onTouchMove`, `onTouchEnd` để đo khoảng cách dịch chuyển ngón tay (`deltaX`), áp dụng hiệu ứng chuyển dịch `transform: translateX(...)` trực quan và chuyển ảnh (`currentBgIndex`) khi vuốt tay qua ngưỡng 50px.
- **Chặn tải/lưu ảnh cho Public Users**:
- Với khách chưa đăng nhập, áp dụng `draggable="false"`, `onContextMenu={(e) => e.preventDefault()}` và CSS `pointer-events: none` lên các phần tử hình ảnh (ảnh nền, ảnh thumbnail) để chặn thao tác nhấn giữ lưu ảnh hoặc nhấp chuột phải.
#### [MODIFY] [ExploreMap.tsx](file:///home/locpham/travelplanning/frontend/src/pages/ExploreMap.tsx)
- **Chặn lưu ảnh**: Áp dụng chặn drag, chuột phải tương tự LandingPage cho khách vãng lai.
- **Xem Blacklist gần bạn**:
- Trong widget Blacklist, bổ sung nút "Xem địa điểm gần tôi".
- Tính toán khoảng cách giữa tọa độ của các địa điểm blacklist và `userPos` (GPS hiện tại), sắp xếp gần nhất lên trước. Khi click vào một địa điểm, tự động di chuyển bản đồ đến toạ độ đó.
- **Recommended List (Danh sách đề xuất)**:
- Tạo Widget đề xuất (đối xứng hoặc đặt cạnh Blacklist).
- Tải dữ liệu từ `/api/v1/recommendations`, hiển thị danh sách gồm tên cơ sở, loại hình, số sao đánh giá (sao vàng), mô tả.
- Thêm nút "Định vị" trong widget để bay đến vị trí trên bản đồ, hiển thị marker đề xuất (ghim xanh lá/ngôi sao vàng) kèm nút "Xem địa điểm gần tôi" tương tự blacklist.
#### [MODIFY] [MemberDashboard.tsx](file:///home/locpham/travelplanning/frontend/src/pages/MemberDashboard.tsx)
- **Đồng bộ hóa Ngôn ngữ & Theme màu**:
- Đưa phần tuỳ chọn chọn Ngôn ngữ và Theme màu ra ngoài thanh Sidebar chính (hoặc trên thanh header) để người dùng thay đổi trực tiếp bất cứ lúc nào, không bắt buộc phải bật nút chia sẻ cứu hộ.
- Cập nhật CSS của `MemberDashboard.tsx` với đầy đủ tiền tố `dark:` để khi người dùng đổi sang theme sáng, toàn bộ dashboard sẽ chuyển sang giao diện Light Theme sang trọng, đẹp mắt.
- **Hiệu ứng chuông lắc trên Avatar**:
- Định nghĩa keyframes lắc chuông `bell-ring` trong CSS.
- Nếu `unreadChatSenders.length > 0 || unreadTourChats.length > 0 || receivedRequests.length > 0` (người dùng có thông báo mới), áp dụng class `animate-ring` giúp biểu tượng chiếc chuông nhỏ trên avatar của họ lắc lư để thu hút sự chú ý.
#### [MODIFY] [UserManagementModal.tsx](file:///home/locpham/travelplanning/frontend/src/components/UserManagementModal.tsx)
- **Tab quản lý Tours & Notes**: Bổ sung tab xem danh sách Tours và Notes đang hoạt động, cho phép chọn nhiều mục cùng lúc (Multi-select) để chuyển vào Thùng rác.
- **Tab quản lý Thùng rác (Trash Bin)**:
- Chia làm 3 sub-tab: Tours rác, Ảnh rác, Ghi chú rác.
- Hiển thị ngày bị xóa và số ngày đếm ngược còn lại trước khi tự động bị xóa vĩnh viễn.
- Cho phép admin tích chọn nhiều mục để thực hiện **Khôi phục** hoặc **Xóa vĩnh viễn** ngay lập tức.
- Bổ sung ô nhập số ngày lưu trữ (`retentionDays`) tại đầu tab và nút lưu cấu hình.
- **Tab quản lý địa điểm đề xuất**: Admin xem danh sách đề xuất từ người dùng gửi lên, chọn nhiều để Phê duyệt hoặc Xóa bỏ.
---
## Verification Plan
### Automated Tests
- Kiểm tra migration cơ sở dữ liệu:
```bash
npx prisma migrate status --schema=backend/prisma/schema.prisma
```
- Kiểm tra biên dịch TypeScript trên toàn dự án:
```bash
# Backend
cd backend && npx tsc --noEmit
# Frontend
cd frontend && npx tsc --noEmit
```
### Manual Verification
1. **Kiểm tra lắc chuông**: Tạo một tin nhắn chưa đọc từ người dùng khác, xác nhận chuông ở avatar trên Dashboard bắt đầu lắc.
2. **Kiểm tra lướt ảnh LandingPage**: Mở LandingPage bằng giả lập thiết bị mobile trên trình duyệt, vuốt ngang màn hình và kiểm tra ảnh nền cuộn theo tay mượt mà.
3. **Kiểm tra chặn lưu ảnh**: Đăng xuất tài khoản, chuột phải vào ảnh nền/thumbnail ở LandingPage và ExploreMap, xác nhận không hiển thị menu ngữ cảnh lưu ảnh và không thể kéo thả ảnh.
4. **Kiểm tra xuất Excel & PDF**: Nhấn nút xuất PDF lịch trình và xác nhận không còn lỗi oklab, PDF hiển thị sạch sẽ không có giá tiền. Nhấn xuất CSV và import vào Google Sheets kiểm tra hiển thị font Tiếng Việt bình thường.
5. **Kiểm tra phân biệt Ghi chú**: Tạo ghi chú cho Tour A, chuyển sang Tour B và kiểm tra danh sách ghi chú của Tour B trống.
6. **Kiểm tra Blacklist & Recommended gần tôi**: Bật GPS giả lập trên trình duyệt, mở Widget Blacklist và Recommended trên bản đồ, kiểm tra danh sách địa điểm tự động sắp xếp theo thứ tự khoảng cách tăng dần.
7. **Kiểm tra Thùng rác Admin**: Đăng nhập tài khoản admin, xóa nhiều tour/note/ảnh, mở Thùng rác, kiểm tra danh sách đếm ngược ngày xoá, thử khôi phục hoặc xoá vĩnh viễn hàng loạt.
8. **Kiểm tra đổi Theme màu**: Click đổi theme sáng/tối trên dashboard, xác nhận giao diện đổi màu mượt mà theo đúng sắc độ.