fix: build ứng dụng trên android và có thể đăng nhập bằng Google Oauth
@@ -0,0 +1,2 @@
|
||||
VITE_BACKEND_URL=https://yotrip.labz.io.vn
|
||||
VITE_GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_BACKEND_URL=https://yotrip.labz.io.vn
|
||||
VITE_GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
|
||||
@@ -9,7 +9,9 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-camera')
|
||||
implementation project(':capacitor-geolocation')
|
||||
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,24 @@
|
||||
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" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 55 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>
|
||||
|
||||
@@ -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,11 @@
|
||||
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 ':codetrix-studio-capacitor-google-auth'
|
||||
project(':codetrix-studio-capacitor-google-auth').projectDir = new File('../../node_modules/@codetrix-studio/capacitor-google-auth/android')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ext {
|
||||
minSdkVersion = 23
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
|
||||
|
After Width: | Height: | Size: 66 KiB |
@@ -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;
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
|
||||
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://yotrip.labz.io.vn" />
|
||||
<meta property="og:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta property="og: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 property="og:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
|
||||
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://yotrip.labz.io.vn" />
|
||||
<meta property="og:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta property="og: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 property="og:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<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-Do9UKwUA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D58kjk9H.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
@@ -11,9 +11,11 @@
|
||||
},
|
||||
"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",
|
||||
"@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 +31,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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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 và 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>
|
||||
);
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 ký bằng Google
|
||||
</button>
|
||||
) : (
|
||||
<div id="google-signin-btn-signup" className="w-full flex justify-center"></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||