merge: giải quyết xung đột env và dashboard giữa 2 máy
This commit is contained in:
@@ -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,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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user