fix: build ứng dụng trên android và có thể đăng nhập bằng Google Oauth

This commit is contained in:
2026-06-27 10:39:22 +07:00
parent d8bbb22dbd
commit ee42bfcefa
47 changed files with 5149 additions and 124 deletions
+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) => {
+8 -5
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
@@ -32,13 +33,13 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
const mentionRef = useRef<HTMLDivElement>(null);
const getHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`,
'Content-Type': 'application/json'
});
const currentUserId = (() => {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
if (!token) return null;
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
@@ -87,7 +88,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 +180,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', () => {
@@ -311,7 +314,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
const res = await fetch('/api/v1/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
},
body: formData
});
+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(
+4 -1
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,
@@ -540,7 +541,9 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
if (!user?.id) return;
// Connect to WebSocket using same origin/proxy
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', () => {
+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>
+4 -1
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';
@@ -1564,7 +1565,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);
+74
View File
@@ -0,0 +1,74 @@
import { Capacitor } from '@capacitor/core';
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
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);
}
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;
};
}