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

This commit is contained in:
2026-06-27 12:09:01 +07:00
91 changed files with 5533 additions and 412 deletions
+4 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import { useTourStore } from '@/store/useTourStore';
import { ConfirmModal } from './ConfirmModal';
@@ -69,7 +70,9 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
fetchComments();
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
const socket = io();
const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
: io();
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
socket.on('commentAdded', (newCommentData: any) => {
+106 -5
View File
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2, LogIn } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { Capacitor } from '@capacitor/core';
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
interface JoinTourLoginModalProps {
isOpen: boolean;
@@ -119,6 +121,88 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
}
};
const handleNativeGoogleLogin = async () => {
setError('');
setIsLoading(true);
try {
const user = await GoogleAuth.signIn();
console.log('[JoinTourLogin] Native Google user received:', user);
const idToken = user.authentication.idToken;
if (!idToken) {
throw new Error('Không nhận được token từ Google.');
}
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: idToken }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập Google thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
let currentToken = inviteTokenRef.current || localStorage.getItem('pendingInviteToken');
if (!currentToken) {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
if (urlToken) {
currentToken = urlToken;
localStorage.setItem('pendingInviteToken', urlToken);
}
}
if (!currentToken) {
throw new Error('Không có mã lời mời để tham gia tour');
}
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: currentToken }),
});
const joinData = await joinRes.json().catch(() => ({}));
if (joinRes.ok) {
notify({
title: 'Thành công',
message: joinData.message || 'Bạn đã gia nhập tour!',
type: 'success'
});
localStorage.removeItem('pendingInviteToken');
if (onJoinSuccess) {
onJoinSuccess(data.user, joinData);
}
onClose();
} else {
localStorage.removeItem('pendingInviteToken');
const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`;
setError(`Lỗi gia nhập tour: ${errorMessage}`);
setIsLoading(false);
}
} catch (e: any) {
setError(`Lỗi khi gia nhập: ${e.message}`);
setIsLoading(false);
}
} catch (err: any) {
console.error('[JoinTourLogin] Native Google login error:', err);
if (err?.message !== 'Sign in window was closed' && err?.message !== '12501') {
setError(err.message || 'Đăng nhập Google thất bại');
}
setIsLoading(false);
}
};
const handleEmailPasswordJoin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
@@ -182,7 +266,7 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
};
useEffect(() => {
if (!isOpen) return;
if (!isOpen || Capacitor.isNativePlatform()) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
@@ -241,10 +325,27 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
</div>
)}
{/* Google OAuth Button */}
<div className="mb-4 sm:mb-6 flex justify-center">
<div id="google-signin-btn-join-tour" className="w-full" />
</div>
{Capacitor.isNativePlatform() ? (
<div className="mb-4 sm:mb-6 flex justify-center w-full">
<button
type="button"
onClick={handleNativeGoogleLogin}
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-slate-50 text-slate-800 font-bold py-3.5 px-4 rounded-2xl border border-slate-200 transition-all active:scale-[0.98] cursor-pointer text-sm shadow-sm"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="#EA4335"
d="M12.24 10.285V14.4h6.887c-.648 2.41-2.519 4.114-5.136 4.114-3.51 0-6.357-2.89-6.357-6.457 0-3.568 2.848-6.458 6.357-6.458 1.614 0 3.08.618 4.19 1.625l3.03-3.03C19.045 2.11 15.895 1 12.24 1 6.033 1 1 6.033 1 12.24s5.033 11.24 11.24 11.24c5.84 0 10.74-4.14 11.24-9.84v-3.355H12.24z"
/>
</svg>
Đăng nhập bằng Google
</button>
</div>
) : (
<div className="mb-4 sm:mb-6 flex justify-center">
<div id="google-signin-btn-join-tour" className="w-full" />
</div>
)}
<div className="relative mb-4 sm:mb-6">
<div className="absolute inset-0 flex items-center">
+82 -2
View File
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
import { Capacitor } from '@capacitor/core';
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
interface LoginModalProps {
isOpen: boolean;
@@ -101,8 +103,70 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
}
};
const handleNativeGoogleLogin = async () => {
setError('');
setIsLoading(true);
try {
const user = await GoogleAuth.signIn();
console.log('[Native OAuth] Google user received:', user);
const idToken = user.authentication.idToken;
if (!idToken) {
throw new Error('Không nhận được token từ Google.');
}
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: idToken }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập Google thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
console.log('[Native OAuth] Google login successful');
let pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: pendingInviteToken }),
});
if (joinRes.ok) {
localStorage.removeItem('pendingInviteToken');
}
} catch (joinErr) {
console.error('[Native OAuth] Join tour after login failed:', joinErr);
}
}
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
} catch (err: any) {
console.error('[Native OAuth] Error:', err);
if (err?.message !== 'Sign in window was closed' && err?.message !== '12501') {
setError(err.message || 'Đăng nhập Google thất bại');
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
if (!isOpen || Capacitor.isNativePlatform()) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
@@ -244,7 +308,23 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
<span className="relative px-3 bg-[var(--surface)] text-xs font-bold text-[var(--text-muted)] uppercase">Hoặc</span>
</div>
<div id="google-signin-btn-login" className="w-full flex justify-center"></div>
{Capacitor.isNativePlatform() ? (
<button
type="button"
onClick={handleNativeGoogleLogin}
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-slate-50 text-slate-800 font-bold py-3.5 px-4 rounded-2xl border border-slate-200 transition-all active:scale-[0.98] cursor-pointer text-sm shadow-sm"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="#EA4335"
d="M12.24 10.285V14.4h6.887c-.648 2.41-2.519 4.114-5.136 4.114-3.51 0-6.357-2.89-6.357-6.457 0-3.568 2.848-6.458 6.357-6.458 1.614 0 3.08.618 4.19 1.625l3.03-3.03C19.045 2.11 15.895 1 12.24 1 6.033 1 1 6.033 1 12.24s5.033 11.24 11.24 11.24c5.84 0 10.74-4.14 11.24-9.84v-3.355H12.24z"
/>
</svg>
Đăng nhập bằng Google
</button>
) : (
<div id="google-signin-btn-login" className="w-full flex justify-center"></div>
)}
<div className="mt-10 pt-8 border-t border-[var(--border)] text-center">
<p className="text-[var(--text-secondary)]">
+73
View File
@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { Camera as CameraIcon, X } from 'lucide-react';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { useNotification } from '@/hooks/useNotification';
interface PhotoTakerProps {
onPhotoTaken?: (webPath: string) => void;
onClose?: () => void;
}
export const PhotoTaker: React.FC<PhotoTakerProps> = ({ onPhotoTaken, onClose }) => {
const [photoUri, setPhotoUri] = useState<string | undefined>();
const notify = useNotification();
const takeAndSavePhoto = async () => {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: false, // Giữ nguyên ảnh gốc, không qua chỉnh sửa
resultType: CameraResultType.Uri,
source: CameraSource.Camera, // Mở camera trực tiếp
saveToGallery: true, // Tự động lưu ảnh gốc vào thư viện điện thoại
});
setPhotoUri(image.webPath);
notify({
title: 'Thành công',
message: 'Ảnh đã được chụp và tự động lưu vào thư viện điện thoại.',
type: 'success'
});
if (onPhotoTaken && image.webPath) {
onPhotoTaken(image.webPath);
}
} catch (error: any) {
console.error('Lỗi khi chụp hoặc lưu ảnh:', error);
if (error?.message !== 'User cancelled photos app') {
notify({
title: 'Lỗi chụp ảnh',
message: 'Không thể truy cập camera hoặc lưu ảnh.',
type: 'error'
});
}
}
};
return (
<div className="flex flex-col items-center justify-center p-6 bg-slate-900/60 border border-slate-800/80 rounded-2xl w-full max-w-md mx-auto backdrop-blur-md">
<div className="flex justify-between items-center w-full mb-4">
<h3 className="text-sm font-black uppercase text-indigo-400 tracking-wider">Chụp nh hành trình</h3>
{onClose && (
<button onClick={onClose} className="p-1 hover:bg-slate-800 rounded-full transition-colors text-slate-400 hover:text-white">
<X className="w-5 h-5" />
</button>
)}
</div>
<button
onClick={takeAndSavePhoto}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-indigo-650 hover:bg-indigo-700 active:scale-[0.98] text-white rounded-xl font-bold transition-all shadow-md cursor-pointer text-xs uppercase tracking-wider"
>
<CameraIcon className="w-4 h-4" />
Chụp Lưu nh Gốc
</button>
{photoUri && (
<div className="mt-6 w-full text-center border border-slate-800/85 bg-slate-950/40 p-4 rounded-xl">
<p className="text-[11px] text-slate-400 mb-2 font-semibold">Xem trước nh vừa chụp:</p>
<img src={photoUri} alt="Xem trước ảnh chụp" className="max-w-full h-auto rounded-lg mx-auto border border-slate-800" />
</div>
)}
</div>
);
};
+4 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import { CoordinateSelectModal } from './CoordinateSelectModal';
import { useTranslation } from '../hooks/useTranslation';
import { useConfirm } from '../hooks/useConfirm';
@@ -246,7 +247,9 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
fetchComments();
const socket = io();
const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
: io();
socket.emit('joinPhoto', photo.id);
socket.on('photoCommentAdded', (newCommentData: any) => {
+42 -3
View File
@@ -1,5 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
@@ -87,7 +89,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
}, []);
const filteredParticipants = participants.filter(p =>
p.name.toLowerCase().includes(mentionSearch.toLowerCase())
p && p.name && p.name.toLowerCase().includes(mentionSearch.toLowerCase())
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -179,7 +181,9 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
// Connect to socket and listen for tour messages
useEffect(() => {
const socket = io();
const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
: io();
socketRef.current = socket;
socket.on('connect', () => {
@@ -252,6 +256,41 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
});
};
// Handle Native Image Selection (Camera or Library)
const handleNativePhotoSelect = async () => {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: false,
resultType: CameraResultType.Uri,
source: CameraSource.Prompt, // Prompt selection
saveToGallery: true, // Auto-save original photo to device gallery
promptLabelHeader: 'Chọn hình ảnh',
promptLabelPhoto: 'Chọn từ thư viện',
promptLabelPicture: 'Chụp ảnh mới (Camera)'
});
if (image && image.webPath) {
setImagePreview(image.webPath);
// Convert Capacitor webPath resource back to standard File instance
const response = await fetch(image.webPath);
const blob = await response.blob();
const file = new File([blob], `photo.${image.format}`, { type: `image/${image.format}` });
setSelectedImage(file);
}
} catch (error: any) {
console.error('Lỗi chọn ảnh native:', error);
if (error?.message !== 'User cancelled photos app' && error?.message !== 'User cancelled camera') {
notify({
title: 'Lỗi',
message: 'Không thể truy cập máy ảnh hoặc thư viện ảnh.',
type: 'error'
});
}
}
};
// Handle Image Selection
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -613,7 +652,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
{/* Attach photo button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onClick={Capacitor.isNativePlatform() ? handleNativePhotoSelect : () => fileInputRef.current?.click()}
className="p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0"
title="Đính kèm hình ảnh"
>