diff --git a/ANDROID_BUILD_GUIDE..md b/ANDROID_BUILD_GUIDE..md deleted file mode 100644 index 1b8dcdc..0000000 --- a/ANDROID_BUILD_GUIDE..md +++ /dev/null @@ -1,201 +0,0 @@ -# 🗺️ 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 - - - - - - - - - - - - - - - - - - ... - - - -## 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(); - - 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 ( - - -
-

Tính năng Chụp ảnh

- - - - Chụp và Lưu Ảnh Gốc - - - {photoUri && ( -
-

Xem trước ảnh vừa chụp:

- Xem trước ảnh chụp -
- )} -
-
-
- ); -}; - -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 \ No newline at end of file diff --git a/FIX_ANDROID.md b/FIX_ANDROID.md new file mode 100644 index 0000000..39e8997 --- /dev/null +++ b/FIX_ANDROID.md @@ -0,0 +1,69 @@ +# To AI Agent: Implement Smart Runtime Environment Detection for Backend URL (Eliminate Dynamic .env Conflicts) + +## 1. Context & Objective +We want to modify the frontend backend-URL resolution engine **exactly once** so that pulling code to the Linux Debian Server (Docker build) or a Windows PC (Android build) requires zero manual `.env` updates. + +- **The Strategy:** Instead of hardcoding `VITE_BACKEND_URL` in a static `.env` file, we will write a smart runtime detector inside the code. +- **The Logic:** + - If the app runs on a standard web browser (Docker Server), `window.location.origin` automatically resolves to `https://yotrip.labz.io.vn`. + - If the app runs inside an Android APK wrapper, the origin defaults to `http://localhost`, `capacitor://`, or `file://`. The code will detect this and automatically inject the absolute server production URL. + +--- + +## 2. Refactoring Blueprint + +### Step 1: Overhaul `socketService.ts` (or your central API configuration file) +Replace the current environment variable lookup with this smart dynamic runtime resolver: + +```typescript +import { io, Socket } from 'socket.io-client'; + +const resolveBackendEndpoint = (): string => { + // 1. Keep a fallback for explicit env overrides if deliberately set + if (import.meta.env.VITE_BACKEND_URL) { + return import.meta.env.VITE_BACKEND_URL; + } + + const origin = window.location.origin; + const userAgent = navigator.userAgent.toLowerCase(); + + /* 2. DETECT ANDROID APK WRAPPER ENVIRONMENT + Android webviews/hybrid shells run on custom local hosts or file protocols + */ + const isAndroidApp = + origin.includes('localhost') || + origin.includes('capacitor://') || + origin.startsWith('file://') || + (userAgent.includes('android') && !origin.includes('yotrip.labz.io.vn')); + + if (isAndroidApp) { + console.log("📱 Android App environment detected. Forcing absolute production domain mapping."); + return '[https://yotrip.labz.io.vn](https://yotrip.labz.io.vn)'; + } + + // 3. Default fallback for Production Web (Docker server automatically yields its own domain) + return origin; +}; + +export const SOCKET_URL = resolveBackendEndpoint(); +console.log(`🌐 Active Network Base Endpoint: ${SOCKET_URL}`); + +### Step 2: Sync API Axios Client Configuration (api.ts / axiosClient.ts) +Ensure your API client hooks directly into the newly created SOCKET_URL variable to keep them unified: + +import axios from 'axios'; +import { SOCKET_URL } from './socketService'; + +export const api = axios.create({ + baseURL: SOCKET_URL, // Dynamically synchronizes across both Docker and Android build pipelines + headers: { + 'Content-Type': 'application/json', + }, +}); + +## 3. Verification Checklist for AI Agent + [ ] Eradicate .env Dependency: Ensure that clearing out VITE_BACKEND_URL from local .env files does not cause code compilation failures. + + [ ] Web Browser Verification: When deployed via Docker on the server, verify the network tab calls requests relatively to the running host origin. + + [ ] Android Simulation Success: When compiled into an APK on Windows, confirm all API/Socket requests direct straight to https://yotrip.labz.io.vn, avoiding any localhost 404 blockages. \ No newline at end of file diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 0d8fda8..e3ae735 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -1,27 +1,27 @@ - - - - - - - - Travel Planner - - - - - - - - - - - - - - - - + + + + + + + + Travel Planner + + + + + + + + + + + + + + + + @@ -31,8 +31,8 @@ - - -
- + + +
+ \ No newline at end of file diff --git a/frontend/src/components/CommentModal.tsx b/frontend/src/components/CommentModal.tsx index e4e6ac0..4b71c58 100644 --- a/frontend/src/components/CommentModal.tsx +++ b/frontend/src/components/CommentModal.tsx @@ -2,6 +2,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 { BACKEND_URL } from '@/utils/backendEndpoint'; import { useTourStore } from '@/store/useTourStore'; import { ConfirmModal } from './ConfirmModal'; @@ -71,7 +72,7 @@ export const CommentModal: React.FC = ({ isOpen, onClose, loc // Lắng nghe bình luận mới qua Proxy (không cần hardcode URL) const socket = Capacitor.isNativePlatform() - ? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn') + ? io(BACKEND_URL) : io(); socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể diff --git a/frontend/src/components/PublicPhotoModal.tsx b/frontend/src/components/PublicPhotoModal.tsx index da0ffa3..69f67fd 100644 --- a/frontend/src/components/PublicPhotoModal.tsx +++ b/frontend/src/components/PublicPhotoModal.tsx @@ -2,6 +2,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 { BACKEND_URL } from '@/utils/backendEndpoint'; import { CoordinateSelectModal } from './CoordinateSelectModal'; import { useTranslation } from '../hooks/useTranslation'; import { useConfirm } from '../hooks/useConfirm'; @@ -248,7 +249,7 @@ export const PublicPhotoModal: React.FC = ({ fetchComments(); const socket = Capacitor.isNativePlatform() - ? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn') + ? io(BACKEND_URL) : io(); socket.emit('joinPhoto', photo.id); diff --git a/frontend/src/components/TourChat.tsx b/frontend/src/components/TourChat.tsx index d69b0db..205fe94 100644 --- a/frontend/src/components/TourChat.tsx +++ b/frontend/src/components/TourChat.tsx @@ -2,6 +2,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 { BACKEND_URL } from '@/utils/backendEndpoint'; import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react'; import { useNotification } from '@/hooks/useNotification'; @@ -182,7 +183,7 @@ export const TourChat: React.FC = ({ tourId, embedded = false }) // Connect to socket and listen for tour messages useEffect(() => { const socket = Capacitor.isNativePlatform() - ? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn') + ? io(BACKEND_URL) : io(); socketRef.current = socket; diff --git a/frontend/src/pages/MemberDashboard.tsx b/frontend/src/pages/MemberDashboard.tsx index 6c9276f..0a6a0f7 100644 --- a/frontend/src/pages/MemberDashboard.tsx +++ b/frontend/src/pages/MemberDashboard.tsx @@ -1,4 +1,10 @@ import React, { useEffect, useState, useRef } from 'react'; +<<<<<<< Updated upstream +======= +import { io, Socket } from 'socket.io-client'; +import { Capacitor } from '@capacitor/core'; +import { BACKEND_URL } from '@/utils/backendEndpoint'; +>>>>>>> Stashed changes import { Compass, Users, @@ -97,6 +103,7 @@ export const MemberDashboard: React.FC = ({ const [chatMessages, setChatMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); const messagesEndRef = useRef(null); + const socketRef = useRef(null); useEffect(() => { @@ -530,14 +537,32 @@ export const MemberDashboard: React.FC = ({ muteNotificationsRef.current = muteNotifications; }); - // Socket connection for realtime messaging - changed to listen to global socket events + // Socket connection for realtime messaging useEffect(() => { if (!user?.id) return; +<<<<<<< Updated upstream const handleMessageReceived = (e: Event) => { const message = (e as CustomEvent).detail; // If we are actively chatting with the sender of this message if (activeChatUser && (message.senderId === activeChatUser.id || message.receiverId === activeChatUser.id)) { +======= + // Connect to WebSocket: absolute URL on native, relative (Vite proxy) on web + const socket = Capacitor.isNativePlatform() + ? io(BACKEND_URL) + : io(); + socketRef.current = socket; + + socket.on('connect', () => { + console.log('[WS] MemberDashboard connected:', socket.id); + socket.emit('joinUser', user.id); + }); + + const handleMessageReceived = (message: any) => { + // Check against window-global activeChatUserId so stale closure doesn't block updates + const activeChatUserId = (window as any).activeChatUserId; + if (activeChatUserId && (message.senderId === activeChatUserId || message.receiverId === activeChatUserId)) { +>>>>>>> Stashed changes setChatMessages(prev => [...prev, message]); } else { // Add to unread states @@ -548,31 +573,24 @@ export const MemberDashboard: React.FC = ({ } }; - const handleConnectionAccepted = () => { + socket.on('messageReceived', handleMessageReceived); + + socket.on('connectionAccepted', () => { fetchConnectionsRef.current(); - }; + }); - const handleTourMessageNotification = (e: Event) => { - const data = (e as CustomEvent).detail; + socket.on('tourMessageNotification', (data: any) => { setUnreadTourChats(prev => prev.includes(data.tourId) ? prev : [...prev, data.tourId]); - }; + }); - const handleJoinRequestAccepted = () => { + socket.on('joinRequestAccepted', () => { fetchPublicToursRef.current(); - }; - - window.addEventListener('app:messageReceived', handleMessageReceived); - window.addEventListener('app:connectionAccepted', handleConnectionAccepted); - window.addEventListener('app:tourMessageNotification', handleTourMessageNotification); - window.addEventListener('app:joinRequestAccepted', handleJoinRequestAccepted); + }); return () => { - window.removeEventListener('app:messageReceived', handleMessageReceived); - window.removeEventListener('app:connectionAccepted', handleConnectionAccepted); - window.removeEventListener('app:tourMessageNotification', handleTourMessageNotification); - window.removeEventListener('app:joinRequestAccepted', handleJoinRequestAccepted); + socket.disconnect(); }; - }, [user?.id, activeChatUser]); + }, [user?.id]); // Autoscroll chat to bottom useEffect(() => { diff --git a/frontend/src/pages/TourDetailPage.tsx b/frontend/src/pages/TourDetailPage.tsx index eebea28..b9a62e0 100644 --- a/frontend/src/pages/TourDetailPage.tsx +++ b/frontend/src/pages/TourDetailPage.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import { io } from 'socket.io-client'; import { Capacitor } from '@capacitor/core'; +import { BACKEND_URL } from '@/utils/backendEndpoint'; import { jsPDF } from 'jspdf'; import autoTable from 'jspdf-autotable'; import { robotoBase64, robotoBoldBase64 } from '../utils/pdfFont'; @@ -1573,7 +1574,7 @@ export const TourDetailPage = ({ if (!currentTour) return; const socket = Capacitor.isNativePlatform() - ? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn') + ? io(BACKEND_URL) : io(); // Kết nối qua Proxy của Vite (cùng origin) socket.on('connect', () => { diff --git a/frontend/src/utils/backendEndpoint.ts b/frontend/src/utils/backendEndpoint.ts new file mode 100644 index 0000000..3ff0e15 --- /dev/null +++ b/frontend/src/utils/backendEndpoint.ts @@ -0,0 +1,46 @@ +/** + * Smart runtime backend URL resolver. + * + * Eliminates the need to manually update `.env` files when switching between: + * - Docker/Linux Production Web Server → uses window.location.origin + * - Android APK (Capacitor WebView) → forces absolute production domain + * + * Priority order: + * 1. Explicit VITE_BACKEND_URL env variable (if set at build time) + * 2. Android/Capacitor app detection → https://yotrip.labz.io.vn + * 3. Production web server → window.location.origin + */ + +const PRODUCTION_DOMAIN = 'https://yotrip.labz.io.vn'; + +const resolveBackendEndpoint = (): string => { + // 1. Honour explicit build-time env override first + const envUrl = import.meta.env.VITE_BACKEND_URL as string | undefined; + if (envUrl) { + return envUrl; + } + + const origin = window.location.origin; + const userAgent = navigator.userAgent.toLowerCase(); + + // 2. Detect Android APK / Capacitor WebView environment + // These shells run on local/file:// origins, not the production domain. + const isAndroidApp = + origin.startsWith('http://localhost') || + origin.startsWith('capacitor://') || + origin.startsWith('file://') || + (userAgent.includes('android') && !origin.includes('yotrip.labz.io.vn')); + + if (isAndroidApp) { + console.log('[Backend] 📱 Android/Capacitor environment detected — using production domain.'); + return PRODUCTION_DOMAIN; + } + + // 3. Web production server: the origin IS the backend (same Docker host) + return origin; +}; + +/** Resolved backend base URL for the current runtime environment. */ +export const BACKEND_URL: string = resolveBackendEndpoint(); + +console.log(`[Backend] 🌐 Active endpoint: ${BACKEND_URL}`); diff --git a/frontend/src/utils/nativePatch.ts b/frontend/src/utils/nativePatch.ts index f5aa5a4..c57fda6 100644 --- a/frontend/src/utils/nativePatch.ts +++ b/frontend/src/utils/nativePatch.ts @@ -1,6 +1,7 @@ import { Capacitor } from '@capacitor/core'; import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'; import { LocalNotifications } from '@capacitor/local-notifications'; +import { BACKEND_URL } from './backendEndpoint'; function rewriteUrls(obj: any, backendUrl: string): any { if (obj === null || obj === undefined) return obj; @@ -47,7 +48,7 @@ if (Capacitor.isNativePlatform()) { console.error('[LocalNotifications] Failed to request permissions:', e); } - const backendUrl = import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn'; + const backendUrl = BACKEND_URL; const originalFetch = window.fetch; window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => {