Backup: 2025-06-07 19:48 - Script test

[Restore from backup: vip-coordinator-backup-2025-06-07-19-48-script-test]
This commit is contained in:
2025-06-07 19:48:00 +02:00
parent 8fb00ec041
commit dc4655cef4
103 changed files with 16396 additions and 6143 deletions

View File

@@ -0,0 +1,72 @@
import React, { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
onError?: (error: Error) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class AsyncErrorBoundary extends Component<Props, State> {
state: State = {
hasError: false,
error: null
};
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error
};
}
componentDidCatch(error: Error) {
console.error('AsyncErrorBoundary caught an error:', error);
this.props.onError?.(error);
}
retry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<svg
className="w-5 h-5 text-red-400 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 className="text-sm font-medium text-red-800">
Failed to load data
</h3>
</div>
<p className="mt-2 text-sm text-red-700">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={this.retry}
className="mt-3 text-sm text-red-600 hover:text-red-500 underline"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
interface Driver {
id: string;
@@ -35,7 +35,7 @@ const EditDriverForm: React.FC<EditDriverFormProps> = ({ driver, onSubmit, onCan
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
const { name, value } = e.target;
if (name === 'lat' || name === 'lng') {
setFormData(prev => ({

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
interface Flight {
flightNumber: string;
@@ -191,15 +191,6 @@ const EditVipForm: React.FC<EditVipFormProps> = ({ vip, onSubmit, onCancel }) =>
}
};
const formatFlightTime = (timeString: string) => {
if (!timeString) return '';
return new Date(timeString).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">

View File

@@ -0,0 +1,114 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
errorInfo
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null
});
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return <>{this.props.fallback}</>;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md max-w-2xl w-full">
<div className="flex items-center mb-4">
<svg
className="w-8 h-8 text-red-500 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h1 className="text-2xl font-bold text-gray-800">Something went wrong</h1>
</div>
<p className="text-gray-600 mb-6">
We're sorry, but something unexpected happened. Please try refreshing the page or contact support if the problem persists.
</p>
{import.meta.env.DEV && this.state.error && (
<details className="mb-6">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Error details (development mode only)
</summary>
<div className="mt-2 p-4 bg-gray-100 rounded text-xs">
<p className="font-mono text-red-600 mb-2">{this.state.error.toString()}</p>
{this.state.errorInfo && (
<pre className="text-gray-700 overflow-auto">
{this.state.errorInfo.componentStack}
</pre>
)}
</div>
</details>
)}
<div className="flex space-x-4">
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Refresh Page
</button>
<button
onClick={this.handleReset}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
>
Try Again
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
interface ErrorMessageProps {
message: string;
onDismiss?: () => void;
className?: string;
}
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
message,
onDismiss,
className = ''
}) => {
return (
<div className={`bg-red-50 border border-red-200 rounded-lg p-4 ${className}`}>
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3 flex-1">
<p className="text-sm text-red-700">{message}</p>
</div>
{onDismiss && (
<div className="ml-auto pl-3">
<button
onClick={onDismiss}
className="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50"
>
<span className="sr-only">Dismiss</span>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
)}
</div>
</div>
);
};

View File

@@ -1,4 +1,3 @@
import React from 'react';
interface GanttEvent {
id: string;
@@ -163,7 +162,7 @@ const GanttChart: React.FC<GanttChartProps> = ({ events, driverName }) => {
{/* Events */}
<div style={{ padding: '1rem 0' }}>
{events.map((event, index) => {
{events.map((event) => {
const position = calculateEventPosition(event, timeRange);
return (
<div

View File

@@ -0,0 +1,104 @@
import { useEffect, useRef } from 'react';
interface GoogleLoginProps {
onSuccess: (user: any, token: string) => void;
onError: (error: string) => void;
}
// Helper to decode JWT token
function parseJwt(token: string) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
} catch (e) {
return null;
}
}
declare global {
interface Window {
google: any;
}
}
const GoogleLogin: React.FC<GoogleLoginProps> = ({ onSuccess, onError }) => {
const buttonRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Initialize Google Sign-In
const initializeGoogleSignIn = () => {
if (!window.google) {
setTimeout(initializeGoogleSignIn, 100);
return;
}
window.google.accounts.id.initialize({
client_id: '308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com',
callback: handleCredentialResponse,
auto_select: false,
cancel_on_tap_outside: true,
});
// Render the button
if (buttonRef.current) {
window.google.accounts.id.renderButton(
buttonRef.current,
{
theme: 'outline',
size: 'large',
text: 'signin_with',
shape: 'rectangular',
logo_alignment: 'center',
width: 300,
}
);
}
};
const handleCredentialResponse = async (response: any) => {
try {
// Send the Google credential to our backend
const res = await fetch('/auth/google/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ credential: response.credential }),
});
const data = await res.json();
if (!res.ok) {
if (data.error === 'pending_approval') {
// User created but needs approval - still successful login
onSuccess(data.user, data.token);
} else {
throw new Error(data.error || 'Authentication failed');
}
} else {
onSuccess(data.user, data.token);
}
} catch (error) {
console.error('Error during authentication:', error);
onError(error instanceof Error ? error.message : 'Failed to process authentication');
}
};
initializeGoogleSignIn();
}, [onSuccess, onError]);
return (
<div className="flex flex-col items-center">
<div ref={buttonRef} className="google-signin-button"></div>
<p className="mt-4 text-sm text-gray-600">
Sign in with your Google account to continue
</p>
</div>
);
};
export default GoogleLogin;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { apiCall } from '../utils/api';
interface GoogleOAuthButtonProps {
onSuccess: (user: any, token: string) => void;
onError: (error: string) => void;
}
const GoogleOAuthButton: React.FC<GoogleOAuthButtonProps> = ({ onSuccess, onError }) => {
const handleGoogleLogin = async () => {
try {
// Get the OAuth URL from backend
const { data } = await apiCall('/auth/google/url');
if (data && data.url) {
// Redirect to Google OAuth (no popup to avoid CORS issues)
window.location.href = data.url;
} else {
onError('Failed to get authentication URL');
}
} catch (error) {
console.error('Error initiating Google login:', error);
onError('Failed to start authentication');
}
};
return (
<button
onClick={handleGoogleLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg shadow-sm bg-white hover:bg-gray-50 transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path fill="#4285F4" d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"/>
<path fill="#34A853" d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"/>
<path fill="#FBBC05" d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"/>
<path fill="#EA4335" d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"/>
</g>
</svg>
<span className="text-sm font-medium text-gray-700">Sign in with Google</span>
</button>
);
};
export default GoogleOAuthButton;

View File

@@ -0,0 +1,45 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
message?: string;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
message
}) => {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12'
};
return (
<div className="flex flex-col items-center justify-center p-4">
<svg
className={`animate-spin ${sizeClasses[size]} text-blue-500`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{message && (
<p className="mt-2 text-sm text-gray-600">{message}</p>
)}
</div>
);
};

View File

@@ -65,7 +65,12 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
'Authorization': `Bearer ${token}`
}
})
.then(res => res.json())
.then(res => {
if (!res.ok) {
throw new Error(`Failed to get user info: ${res.status} ${res.statusText}`);
}
return res.json();
})
.then(user => {
onLogin(user);
// Clean up URL and redirect to dashboard
@@ -73,6 +78,7 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
})
.catch(error => {
console.error('Error getting user info:', error);
alert('Login failed. Please try again.');
localStorage.removeItem('authToken');
// Clean up URL
window.history.replaceState({}, document.title, '/');

View File

@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { apiCall } from '../utils/api';
const OAuthCallback: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(true);
useEffect(() => {
const handleCallback = async () => {
// Check for errors from OAuth provider
const errorParam = searchParams.get('error');
if (errorParam) {
setError(`Authentication failed: ${errorParam}`);
setProcessing(false);
return;
}
// Get the authorization code
const code = searchParams.get('code');
if (!code) {
setError('No authorization code received');
setProcessing(false);
return;
}
try {
// Exchange the code for a token
const response = await apiCall('/auth/google/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
if (response.error === 'pending_approval') {
// User needs approval
localStorage.setItem('authToken', response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user));
navigate('/pending-approval');
return;
}
if (response.data && response.data.token) {
// Success! Store the token and user data
localStorage.setItem('authToken', response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user));
// Redirect to dashboard
window.location.href = '/';
} else {
setError('Failed to authenticate');
}
} catch (err: any) {
console.error('OAuth callback error:', err);
if (err.message?.includes('pending_approval')) {
// This means the user was created but needs approval
navigate('/');
} else {
setError(err.message || 'Authentication failed');
}
} finally {
setProcessing(false);
}
};
handleCallback();
}, [navigate, searchParams]);
if (processing) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl p-8 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent mx-auto mb-4"></div>
<p className="text-lg font-medium text-slate-700">Completing sign in...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="text-xl font-bold text-slate-800 mb-2">Authentication Failed</h2>
<p className="text-slate-600 mb-4">{error}</p>
<button
onClick={() => navigate('/')}
className="btn btn-primary"
>
Back to Login
</button>
</div>
</div>
);
}
return null;
};
export default OAuthCallback;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { apiCall } from '../config/api';
import DriverSelector from './DriverSelector';
@@ -381,7 +381,7 @@ interface ScheduleEventFormProps {
onCancel: () => void;
}
const ScheduleEventForm: React.FC<ScheduleEventFormProps> = ({ vipId, event, onSubmit, onCancel }) => {
const ScheduleEventForm: React.FC<ScheduleEventFormProps> = ({ event, onSubmit, onCancel }) => {
const [formData, setFormData] = useState({
title: event?.title || '',
location: event?.location || '',

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { API_BASE_URL } from '../config/api';
interface User {
@@ -131,7 +131,7 @@ const UserManagement: React.FC<UserManagementProps> = ({ currentUser }) => {
}
};
const approveUser = async (userEmail: string, userName: string) => {
const approveUser = async (userEmail: string) => {
setUpdatingUser(userEmail);
try {
const token = localStorage.getItem('authToken');
@@ -424,7 +424,7 @@ const UserManagement: React.FC<UserManagementProps> = ({ currentUser }) => {
<div className="flex items-center space-x-3">
<button
onClick={() => approveUser(user.email, user.name)}
onClick={() => approveUser(user.email)}
disabled={updatingUser === user.email}
className={`px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md transition-colors ${
updatingUser === user.email ? 'opacity-50 cursor-not-allowed' : ''

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiCall } from '../utils/api';
import { useToast } from '../contexts/ToastContext';
import { LoadingSpinner } from './LoadingSpinner';
interface OnboardingData {
requestedRole: 'coordinator' | 'driver' | 'viewer';
phone: string;
organization: string;
reason: string;
// Driver-specific fields
vehicleType?: string;
vehicleCapacity?: number;
licensePlate?: string;
}
const UserOnboarding: React.FC = () => {
const navigate = useNavigate();
const { showToast } = useToast();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<OnboardingData>({
requestedRole: 'viewer',
phone: '',
organization: '',
reason: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const token = localStorage.getItem('authToken');
const response = await apiCall('/api/users/complete-onboarding', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
onboardingData: formData,
phone: formData.phone,
organization: formData.organization,
}),
});
if (response.ok) {
showToast('Onboarding completed! Your account is pending approval.', 'success');
navigate('/pending-approval');
} else {
showToast('Failed to complete onboarding. Please try again.', 'error');
}
} catch (error) {
showToast('An error occurred. Please try again.', 'error');
} finally {
setLoading(false);
}
};
const handleRoleChange = (role: 'coordinator' | 'driver' | 'viewer') => {
setFormData(prev => ({
...prev,
requestedRole: role,
// Clear driver fields if not driver
vehicleType: role === 'driver' ? prev.vehicleType : undefined,
vehicleCapacity: role === 'driver' ? prev.vehicleCapacity : undefined,
licensePlate: role === 'driver' ? prev.licensePlate : undefined,
}));
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2">Welcome to VIP Coordinator</h1>
<p className="text-slate-600">Please complete your profile to request access</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Role Selection */}
<div className="form-section">
<label className="block text-sm font-medium text-slate-700 mb-3">
What type of access do you need?
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
type="button"
onClick={() => handleRoleChange('coordinator')}
className={`p-4 rounded-lg border-2 transition-all ${
formData.requestedRole === 'coordinator'
? 'border-amber-500 bg-amber-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-2">📋</div>
<div className="font-semibold text-slate-800">Coordinator</div>
<div className="text-xs text-slate-600 mt-1">Manage VIPs & schedules</div>
</button>
<button
type="button"
onClick={() => handleRoleChange('driver')}
className={`p-4 rounded-lg border-2 transition-all ${
formData.requestedRole === 'driver'
? 'border-amber-500 bg-amber-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-2">🚗</div>
<div className="font-semibold text-slate-800">Driver</div>
<div className="text-xs text-slate-600 mt-1">Transport VIPs</div>
</button>
<button
type="button"
onClick={() => handleRoleChange('viewer')}
className={`p-4 rounded-lg border-2 transition-all ${
formData.requestedRole === 'viewer'
? 'border-amber-500 bg-amber-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-2">👁</div>
<div className="font-semibold text-slate-800">Viewer</div>
<div className="text-xs text-slate-600 mt-1">View-only access</div>
</button>
</div>
</div>
{/* Common Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Phone Number *
</label>
<input
type="tel"
required
value={formData.phone}
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
className="form-input w-full"
placeholder="+1 (555) 123-4567"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Organization *
</label>
<input
type="text"
required
value={formData.organization}
onChange={(e) => setFormData(prev => ({ ...prev, organization: e.target.value }))}
className="form-input w-full"
placeholder="Your company or department"
/>
</div>
</div>
{/* Driver-specific Fields */}
{formData.requestedRole === 'driver' && (
<div className="space-y-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
<h3 className="font-semibold text-slate-800 mb-3">Driver Information</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Vehicle Type *
</label>
<select
required
value={formData.vehicleType || ''}
onChange={(e) => setFormData(prev => ({ ...prev, vehicleType: e.target.value }))}
className="form-select w-full"
>
<option value="">Select vehicle type</option>
<option value="sedan">Sedan</option>
<option value="suv">SUV</option>
<option value="van">Van</option>
<option value="minibus">Minibus</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Passenger Capacity *
</label>
<input
type="number"
required
min="1"
max="20"
value={formData.vehicleCapacity || ''}
onChange={(e) => setFormData(prev => ({ ...prev, vehicleCapacity: parseInt(e.target.value) }))}
className="form-input w-full"
placeholder="4"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
License Plate *
</label>
<input
type="text"
required
value={formData.licensePlate || ''}
onChange={(e) => setFormData(prev => ({ ...prev, licensePlate: e.target.value }))}
className="form-input w-full"
placeholder="ABC-1234"
/>
</div>
</div>
</div>
)}
{/* Reason for Access */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Why do you need access? *
</label>
<textarea
required
rows={3}
value={formData.reason}
onChange={(e) => setFormData(prev => ({ ...prev, reason: e.target.value }))}
className="form-textarea w-full"
placeholder="Please explain your role and why you need access to the VIP Coordinator system..."
/>
</div>
{/* Submit Button */}
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={() => navigate('/')}
className="btn btn-secondary"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="btn btn-primary"
>
{loading ? <LoadingSpinner size="sm" /> : 'Submit Request'}
</button>
</div>
</form>
</div>
</div>
);
};
export default UserOnboarding;

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '../../tests/test-utils';
import GoogleLogin from '../GoogleLogin';
describe('GoogleLogin', () => {
const mockOnSuccess = vi.fn();
const mockOnError = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders login button', () => {
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
// Check if Google button container is rendered
const buttonContainer = screen.getByTestId('google-signin-button');
expect(buttonContainer).toBeInTheDocument();
});
it('initializes Google Identity Services on mount', () => {
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
expect(google.accounts.id.initialize).toHaveBeenCalledWith({
client_id: expect.any(String),
callback: expect.any(Function),
auto_select: true,
cancel_on_tap_outside: false,
});
expect(google.accounts.id.renderButton).toHaveBeenCalled();
});
it('handles successful login', async () => {
// Get the callback function passed to initialize
let googleCallback: any;
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
googleCallback = config.callback;
});
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
// Mock successful server response
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
token: 'test-jwt-token',
user: {
id: '123',
email: 'test@example.com',
name: 'Test User',
role: 'coordinator',
},
}),
});
// Simulate Google credential response
const mockCredential = { credential: 'mock-google-credential' };
await googleCallback(mockCredential);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/auth/google/verify'),
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ credential: 'mock-google-credential' }),
})
);
});
await waitFor(() => {
expect(mockOnSuccess).toHaveBeenCalledWith({
token: 'test-jwt-token',
user: {
id: '123',
email: 'test@example.com',
name: 'Test User',
role: 'coordinator',
},
});
});
});
it('handles login error', async () => {
let googleCallback: any;
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
googleCallback = config.callback;
});
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
// Mock error response
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ error: 'Invalid credential' }),
});
const mockCredential = { credential: 'invalid-credential' };
await googleCallback(mockCredential);
await waitFor(() => {
expect(mockOnError).toHaveBeenCalledWith('Authentication failed');
});
});
it('handles network error', async () => {
let googleCallback: any;
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
googleCallback = config.callback;
});
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
// Mock network error
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
const mockCredential = { credential: 'mock-credential' };
await googleCallback(mockCredential);
await waitFor(() => {
expect(mockOnError).toHaveBeenCalledWith('Network error. Please try again.');
});
});
it('displays loading state during authentication', async () => {
let googleCallback: any;
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
googleCallback = config.callback;
});
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
// Mock a delayed response
(global.fetch as any).mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve({
ok: true,
json: async () => ({ token: 'test-token', user: {} }),
}), 100))
);
const mockCredential = { credential: 'mock-credential' };
googleCallback(mockCredential);
// Check for loading state
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
// Wait for authentication to complete
await waitFor(() => {
expect(screen.queryByText('Authenticating...')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,196 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '../../tests/test-utils';
import userEvent from '@testing-library/user-event';
import VipForm from '../VipForm';
describe('VipForm', () => {
const mockOnSubmit = vi.fn();
const mockOnCancel = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders all form fields', () => {
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
expect(screen.getByLabelText(/full name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
expect(screen.getByLabelText(/organization/i)).toBeInTheDocument();
expect(screen.getByLabelText(/contact information/i)).toBeInTheDocument();
expect(screen.getByLabelText(/arrival date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/departure date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/transportation mode/i)).toBeInTheDocument();
expect(screen.getByLabelText(/hotel/i)).toBeInTheDocument();
expect(screen.getByLabelText(/room number/i)).toBeInTheDocument();
expect(screen.getByLabelText(/additional notes/i)).toBeInTheDocument();
});
it('shows flight-specific fields when flight mode is selected', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
const transportSelect = screen.getByLabelText(/transportation mode/i);
await user.selectOptions(transportSelect, 'flight');
expect(screen.getByLabelText(/airport/i)).toBeInTheDocument();
expect(screen.getByLabelText(/flight number/i)).toBeInTheDocument();
});
it('hides flight fields when self-driving mode is selected', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
// First select flight to show fields
const transportSelect = screen.getByLabelText(/transportation mode/i);
await user.selectOptions(transportSelect, 'flight');
expect(screen.getByLabelText(/airport/i)).toBeInTheDocument();
// Then switch to self-driving
await user.selectOptions(transportSelect, 'self_driving');
expect(screen.queryByLabelText(/airport/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/flight number/i)).not.toBeInTheDocument();
});
it('submits form with valid data', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
// Fill out the form
await user.type(screen.getByLabelText(/full name/i), 'John Doe');
await user.type(screen.getByLabelText(/title/i), 'CEO');
await user.type(screen.getByLabelText(/organization/i), 'Test Corp');
await user.type(screen.getByLabelText(/contact information/i), '+1234567890');
await user.type(screen.getByLabelText(/arrival date/i), '2025-01-15T10:00');
await user.type(screen.getByLabelText(/departure date/i), '2025-01-16T14:00');
await user.selectOptions(screen.getByLabelText(/transportation mode/i), 'flight');
await user.type(screen.getByLabelText(/airport/i), 'LAX');
await user.type(screen.getByLabelText(/flight number/i), 'AA123');
await user.type(screen.getByLabelText(/hotel/i), 'Hilton');
await user.type(screen.getByLabelText(/room number/i), '1234');
await user.type(screen.getByLabelText(/additional notes/i), 'VIP guest');
// Submit the form
const submitButton = screen.getByRole('button', { name: /add vip/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
name: 'John Doe',
title: 'CEO',
organization: 'Test Corp',
contact_info: '+1234567890',
arrival_datetime: '2025-01-15T10:00',
departure_datetime: '2025-01-16T14:00',
transportation_mode: 'flight',
airport: 'LAX',
flight_number: 'AA123',
hotel: 'Hilton',
room_number: '1234',
notes: 'VIP guest',
status: 'scheduled',
});
});
});
it('validates required fields', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
// Try to submit empty form
const submitButton = screen.getByRole('button', { name: /add vip/i });
await user.click(submitButton);
// Check that onSubmit was not called
expect(mockOnSubmit).not.toHaveBeenCalled();
// Check for HTML5 validation (browser will show validation messages)
const nameInput = screen.getByLabelText(/full name/i) as HTMLInputElement;
expect(nameInput.validity.valid).toBe(false);
});
it('calls onCancel when cancel button is clicked', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
it('pre-fills form when editing existing VIP', () => {
const existingVip = {
id: '123',
name: 'Jane Smith',
title: 'VP Sales',
organization: 'Another Corp',
contact_info: '+0987654321',
arrival_datetime: '2025-01-15T14:00',
departure_datetime: '2025-01-16T10:00',
transportation_mode: 'self_driving' as const,
hotel: 'Marriott',
room_number: '567',
status: 'scheduled' as const,
notes: 'Arrives by car',
};
render(
<VipForm
vip={existingVip}
onSubmit={mockOnSubmit}
onCancel={mockOnCancel}
/>
);
expect(screen.getByDisplayValue('Jane Smith')).toBeInTheDocument();
expect(screen.getByDisplayValue('VP Sales')).toBeInTheDocument();
expect(screen.getByDisplayValue('Another Corp')).toBeInTheDocument();
expect(screen.getByDisplayValue('+0987654321')).toBeInTheDocument();
expect(screen.getByDisplayValue('2025-01-15T14:00')).toBeInTheDocument();
expect(screen.getByDisplayValue('2025-01-16T10:00')).toBeInTheDocument();
expect(screen.getByDisplayValue('self_driving')).toBeInTheDocument();
expect(screen.getByDisplayValue('Marriott')).toBeInTheDocument();
expect(screen.getByDisplayValue('567')).toBeInTheDocument();
expect(screen.getByDisplayValue('Arrives by car')).toBeInTheDocument();
// Should show "Update VIP" instead of "Add VIP"
expect(screen.getByRole('button', { name: /update vip/i })).toBeInTheDocument();
});
it('validates departure date is after arrival date', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
// Set departure before arrival
await user.type(screen.getByLabelText(/arrival date/i), '2025-01-16T14:00');
await user.type(screen.getByLabelText(/departure date/i), '2025-01-15T10:00');
// Fill other required fields
await user.type(screen.getByLabelText(/full name/i), 'John Doe');
await user.type(screen.getByLabelText(/title/i), 'CEO');
await user.type(screen.getByLabelText(/organization/i), 'Test Corp');
const submitButton = screen.getByRole('button', { name: /add vip/i });
await user.click(submitButton);
// Form should not submit
expect(mockOnSubmit).not.toHaveBeenCalled();
});
});