Modernization Project Complete. Updated to latest versions of frameworks.

This commit is contained in:
Eric Gullickson
2025-08-24 09:49:21 -05:00
parent 673fe7ce91
commit b534e92636
46 changed files with 2341 additions and 5267 deletions

View File

@@ -12,6 +12,19 @@ RUN npm install && npm cache clean --force
# Stage 3: Build stage
FROM deps AS build
# Accept build arguments for environment variables
ARG VITE_AUTH0_DOMAIN
ARG VITE_AUTH0_CLIENT_ID
ARG VITE_AUTH0_AUDIENCE
ARG VITE_API_BASE_URL
# Set environment variables from build args
ENV VITE_AUTH0_DOMAIN=$VITE_AUTH0_DOMAIN
ENV VITE_AUTH0_CLIENT_ID=$VITE_AUTH0_CLIENT_ID
ENV VITE_AUTH0_AUDIENCE=$VITE_AUTH0_AUDIENCE
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY . .
RUN npm run build:docker

View File

@@ -45,6 +45,7 @@
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"terser": "^5.24.0",
"typescript": "^5.6.3",
"vite": "^5.0.6",
"vitest": "^1.0.1",

View File

@@ -2,7 +2,7 @@
* @ai-summary Main app component with routing and mobile navigation
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useTransition, lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { motion, AnimatePresence } from 'framer-motion';
@@ -14,17 +14,21 @@ import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRound
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import { md3Theme } from './shared-minimal/theme/md3Theme';
import { Layout } from './components/Layout';
import { VehiclesPage } from './features/vehicles/pages/VehiclesPage';
import { VehiclesMobileScreen } from './features/vehicles/mobile/VehiclesMobileScreen';
import { VehicleDetailMobile } from './features/vehicles/mobile/VehicleDetailMobile';
// Lazy load route components for better initial bundle size
const VehiclesPage = lazy(() => import('./features/vehicles/pages/VehiclesPage').then(m => ({ default: m.VehiclesPage })));
const VehiclesMobileScreen = lazy(() => import('./features/vehicles/mobile/VehiclesMobileScreen').then(m => ({ default: m.VehiclesMobileScreen })));
const VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile })));
import { BottomNavigation, NavigationItem } from './shared-minimal/components/mobile/BottomNavigation';
import { GlassCard } from './shared-minimal/components/mobile/GlassCard';
import { Button } from './shared-minimal/components/Button';
import { RouteSuspense } from './components/SuspenseWrappers';
import { Vehicle } from './features/vehicles/types/vehicles.types';
function App() {
const { isLoading, isAuthenticated, loginWithRedirect } = useAuth0();
const [_isPending, startTransition] = useTransition();
// Mobile navigation state - detect mobile screen size with responsive updates
const [mobileMode, setMobileMode] = useState(() => {
@@ -210,7 +214,10 @@ function App() {
<BottomNavigation
items={mobileNavItems}
activeItem={activeScreen}
onItemSelect={setActiveScreen}
onItemSelect={(screen) => startTransition(() => {
setActiveScreen(screen);
setSelectedVehicle(null); // Reset selected vehicle on navigation
})}
/>
</ThemeProvider>
);
@@ -221,15 +228,17 @@ function App() {
<ThemeProvider theme={md3Theme}>
<CssBaseline />
<Layout mobileMode={false}>
<Routes>
<Route path="/" element={<Navigate to="/vehicles" replace />} />
<Route path="/vehicles" element={<VehiclesPage />} />
<Route path="/vehicles/:id" element={<div>Vehicle Details (TODO)</div>} />
<Route path="/fuel-logs" element={<div>Fuel Logs (TODO)</div>} />
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
<Route path="/stations" element={<div>Stations (TODO)</div>} />
<Route path="*" element={<Navigate to="/vehicles" replace />} />
</Routes>
<RouteSuspense>
<Routes>
<Route path="/" element={<Navigate to="/vehicles" replace />} />
<Route path="/vehicles" element={<VehiclesPage />} />
<Route path="/vehicles/:id" element={<div>Vehicle Details (TODO)</div>} />
<Route path="/fuel-logs" element={<div>Fuel Logs (TODO)</div>} />
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
<Route path="/stations" element={<div>Stations (TODO)</div>} />
<Route path="*" element={<Navigate to="/vehicles" replace />} />
</Routes>
</RouteSuspense>
<DebugInfo />
</Layout>
</ThemeProvider>

View File

@@ -0,0 +1,99 @@
/**
* @ai-summary React 19 Suspense wrapper components for different UI sections
* @ai-context Reusable Suspense boundaries with appropriate fallbacks
*/
import React, { Suspense } from 'react';
import { VehicleListSkeleton } from '../shared-minimal/components/skeletons/VehicleListSkeleton';
import { VehicleCardSkeleton } from '../shared-minimal/components/skeletons/VehicleCardSkeleton';
import { MobileVehiclesSkeleton } from '../shared-minimal/components/skeletons/MobileVehiclesSkeleton';
import { Box, Skeleton } from '@mui/material';
interface SuspenseWrapperProps {
children: React.ReactNode;
}
// Vehicle list suspense for desktop
export const VehicleListSuspense: React.FC<SuspenseWrapperProps> = ({ children }) => (
<Suspense fallback={<VehicleListSkeleton />}>
{children}
</Suspense>
);
// Individual vehicle card suspense
export const VehicleCardSuspense: React.FC<SuspenseWrapperProps> = ({ children }) => (
<Suspense fallback={<VehicleCardSkeleton />}>
{children}
</Suspense>
);
// Mobile vehicles screen suspense
export const MobileVehiclesSuspense: React.FC<SuspenseWrapperProps> = ({ children }) => (
<Suspense fallback={<MobileVehiclesSkeleton />}>
{children}
</Suspense>
);
// Authentication state suspense
export const AuthSuspense: React.FC<SuspenseWrapperProps> = ({ children }) => (
<Suspense fallback={
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh'
}}>
<div className="text-center space-y-4">
<Skeleton variant="circular" width={60} height={60} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width={200} height={24} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width={150} height={20} sx={{ mx: 'auto' }} />
</div>
</Box>
}>
{children}
</Suspense>
);
// Route-level suspense for navigation transitions
export const RouteSuspense: React.FC<SuspenseWrapperProps> = ({ children }) => (
<Suspense fallback={
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '400px'
}}>
<div className="text-center space-y-3">
<Skeleton variant="rectangular" width={300} height={40} sx={{ mx: 'auto', borderRadius: 1 }} />
<Skeleton variant="rectangular" width={400} height={200} sx={{ mx: 'auto', borderRadius: 1 }} />
<div className="flex justify-center space-x-2">
<Skeleton variant="rounded" width={80} height={32} />
<Skeleton variant="rounded" width={80} height={32} />
</div>
</div>
</Box>
}>
{children}
</Suspense>
);
// Form suspense for dynamic form loading
export const FormSuspense: React.FC<SuspenseWrapperProps> = ({ children }) => (
<Suspense fallback={
<Box sx={{ p: 3 }}>
<Skeleton variant="text" width="40%" height={28} sx={{ mb: 3 }} />
{Array.from({ length: 4 }).map((_, index) => (
<Box key={index} sx={{ mb: 2 }}>
<Skeleton variant="text" width="30%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="rectangular" width="100%" height={40} sx={{ borderRadius: 1 }} />
</Box>
))}
<Box sx={{ display: 'flex', gap: 2, mt: 3 }}>
<Skeleton variant="rounded" width={100} height={36} />
<Skeleton variant="rounded" width={80} height={36} />
</Box>
</Box>
}>
{children}
</Suspense>
);

View File

@@ -0,0 +1,136 @@
/**
* @ai-summary React 19 useOptimistic hook for vehicle operations
* @ai-context Provides optimistic updates for better perceived performance
*/
import { useOptimistic, useTransition } from 'react';
import { Vehicle, CreateVehicleRequest } from '../types/vehicles.types';
import { useQueryClient } from '@tanstack/react-query';
import { vehiclesApi } from '../api/vehicles.api';
import toast from 'react-hot-toast';
type VehicleAction =
| { type: 'add'; vehicle: Vehicle }
| { type: 'delete'; id: string }
| { type: 'update'; id: string; updates: Partial<Vehicle> };
function vehicleReducer(state: Vehicle[], action: VehicleAction): Vehicle[] {
switch (action.type) {
case 'add':
return [...state, action.vehicle];
case 'delete':
return state.filter(v => v.id !== action.id);
case 'update':
return state.map(v =>
v.id === action.id ? { ...v, ...action.updates } : v
);
default:
return state;
}
}
export function useOptimisticVehicles(vehicles: Vehicle[] = []) {
const [optimisticVehicles, addOptimistic] = useOptimistic(
vehicles,
vehicleReducer
);
const [isPending, startTransition] = useTransition();
const queryClient = useQueryClient();
const optimisticCreateVehicle = async (data: CreateVehicleRequest) => {
// Create optimistic vehicle with temporary ID
const optimisticVehicle: Vehicle = {
id: `temp-${Date.now()}`,
vin: data.vin,
nickname: data.nickname,
color: data.color,
licensePlate: data.licensePlate,
odometerReading: data.odometerReading || 0,
make: undefined,
model: undefined,
year: undefined,
engine: undefined,
transmission: undefined,
trimLevel: undefined,
driveType: undefined,
fuelType: undefined,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: 'current-user' // This would come from auth context
};
// Show optimistic update immediately
startTransition(() => {
addOptimistic({ type: 'add', vehicle: optimisticVehicle });
});
try {
// Perform actual API call
const createdVehicle = await vehiclesApi.create(data);
// Invalidate and refetch to get real data
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle added successfully');
return createdVehicle;
} catch (error: any) {
// Revert optimistic update on error
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.error(error.response?.data?.error || 'Failed to add vehicle');
throw error;
}
};
const optimisticDeleteVehicle = async (id: string) => {
// Show optimistic delete immediately
startTransition(() => {
addOptimistic({ type: 'delete', id });
});
try {
// Perform actual API call
await vehiclesApi.delete(id);
// Invalidate and refetch to ensure consistency
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle deleted successfully');
} catch (error: any) {
// Revert optimistic update on error
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.error(error.response?.data?.error || 'Failed to delete vehicle');
throw error;
}
};
const optimisticUpdateVehicle = async (id: string, updates: Partial<Vehicle>) => {
// Show optimistic update immediately
startTransition(() => {
addOptimistic({ type: 'update', id, updates });
});
try {
// Perform actual API call
const updatedVehicle = await vehiclesApi.update(id, updates);
// Invalidate and refetch to get real data
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle updated successfully');
return updatedVehicle;
} catch (error: any) {
// Revert optimistic update on error
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.error(error.response?.data?.error || 'Failed to update vehicle');
throw error;
}
};
return {
optimisticVehicles,
isPending,
optimisticCreateVehicle,
optimisticDeleteVehicle,
optimisticUpdateVehicle
};
}

View File

@@ -0,0 +1,191 @@
/**
* @ai-summary React 19 useTransition hooks for vehicle operations
* @ai-context Non-blocking updates for better UI responsiveness
*/
import { useTransition, useState, useCallback } from 'react';
import { Vehicle } from '../types/vehicles.types';
export function useVehicleSearch(vehicles: Vehicle[]) {
const [isPending, startTransition] = useTransition();
const [searchQuery, setSearchQuery] = useState('');
const [filteredVehicles, setFilteredVehicles] = useState<Vehicle[]>(vehicles);
const handleSearch = useCallback((query: string) => {
// High priority: Update search input immediately
setSearchQuery(query);
// Low priority: Update filtered results (non-blocking)
startTransition(() => {
if (!query.trim()) {
setFilteredVehicles(vehicles);
} else {
const filtered = vehicles.filter(vehicle => {
const searchTerm = query.toLowerCase();
return (
vehicle.nickname?.toLowerCase().includes(searchTerm) ||
vehicle.make?.toLowerCase().includes(searchTerm) ||
vehicle.model?.toLowerCase().includes(searchTerm) ||
vehicle.vin.toLowerCase().includes(searchTerm) ||
vehicle.year?.toString().includes(searchTerm)
);
});
setFilteredVehicles(filtered);
}
});
}, [vehicles]);
const clearSearch = useCallback(() => {
setSearchQuery('');
startTransition(() => {
setFilteredVehicles(vehicles);
});
}, [vehicles]);
// Update filtered vehicles when vehicles data changes
const updateVehicles = useCallback((newVehicles: Vehicle[]) => {
startTransition(() => {
if (!searchQuery.trim()) {
setFilteredVehicles(newVehicles);
} else {
// Re-apply search filter to new data
const filtered = newVehicles.filter(vehicle => {
const searchTerm = searchQuery.toLowerCase();
return (
vehicle.nickname?.toLowerCase().includes(searchTerm) ||
vehicle.make?.toLowerCase().includes(searchTerm) ||
vehicle.model?.toLowerCase().includes(searchTerm) ||
vehicle.vin.toLowerCase().includes(searchTerm) ||
vehicle.year?.toString().includes(searchTerm)
);
});
setFilteredVehicles(filtered);
}
});
}, [searchQuery]);
return {
searchQuery,
filteredVehicles,
isPending,
handleSearch,
clearSearch,
updateVehicles
};
}
export function useVehicleSort(vehicles: Vehicle[]) {
const [isPending, startTransition] = useTransition();
const [sortBy, setSortBy] = useState<'name' | 'make' | 'year' | 'created'>('created');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [sortedVehicles, setSortedVehicles] = useState<Vehicle[]>(vehicles);
const handleSort = useCallback((newSortBy: typeof sortBy, newSortOrder: typeof sortOrder = 'asc') => {
// High priority: Update sort state immediately
setSortBy(newSortBy);
setSortOrder(newSortOrder);
// Low priority: Update sorted results (non-blocking)
startTransition(() => {
const sorted = [...vehicles].sort((a, b) => {
let aValue: string | number | null;
let bValue: string | number | null;
switch (newSortBy) {
case 'name':
aValue = a.nickname || `${a.make || ''} ${a.model || ''}`.trim() || 'Unknown';
bValue = b.nickname || `${b.make || ''} ${b.model || ''}`.trim() || 'Unknown';
break;
case 'make':
aValue = a.make || 'Unknown';
bValue = b.make || 'Unknown';
break;
case 'year':
aValue = a.year || 0;
bValue = b.year || 0;
break;
case 'created':
aValue = a.createdAt;
bValue = b.createdAt;
break;
default:
return 0;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const result = aValue.localeCompare(bValue);
return newSortOrder === 'asc' ? result : -result;
} else {
const result = (aValue as number) - (bValue as number);
return newSortOrder === 'asc' ? result : -result;
}
});
setSortedVehicles(sorted);
});
}, [vehicles]);
// Update sorted vehicles when vehicles data changes
const updateVehicles = useCallback((_newVehicles: Vehicle[]) => {
startTransition(() => {
handleSort(sortBy, sortOrder);
});
}, [sortBy, sortOrder, handleSort]);
return {
sortBy,
sortOrder,
sortedVehicles,
isPending,
handleSort,
updateVehicles
};
}
export function useVehicleFilter(vehicles: Vehicle[]) {
const [isPending, startTransition] = useTransition();
const [activeFilters, setActiveFilters] = useState<{
make?: string;
year?: number;
isActive?: boolean;
}>({});
const [filteredVehicles, setFilteredVehicles] = useState<Vehicle[]>(vehicles);
const applyFilters = useCallback((filters: typeof activeFilters) => {
// High priority: Update filter state immediately
setActiveFilters(filters);
// Low priority: Apply filters (non-blocking)
startTransition(() => {
let filtered = vehicles;
if (filters.make) {
filtered = filtered.filter(v => v.make === filters.make);
}
if (filters.year) {
filtered = filtered.filter(v => v.year === filters.year);
}
if (filters.isActive !== undefined) {
filtered = filtered.filter(v => v.isActive === filters.isActive);
}
setFilteredVehicles(filtered);
});
}, [vehicles]);
const clearFilters = useCallback(() => {
setActiveFilters({});
startTransition(() => {
setFilteredVehicles(vehicles);
});
}, [vehicles]);
return {
activeFilters,
filteredVehicles,
isPending,
applyFilters,
clearFilters
};
}

View File

@@ -1,10 +1,14 @@
/**
* @ai-summary Mobile-optimized vehicles screen with Material Design 3
* @ai-summary Mobile vehicles screen with React 19 enhancements
* @ai-context Enhanced with Suspense, optimistic updates, and transitions
*/
import React from 'react';
import React, { useTransition, useEffect } from 'react';
import { Box, Typography, Grid } from '@mui/material';
import { useVehicles } from '../hooks/useVehicles';
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
import { MobileVehiclesSuspense } from '../../../components/SuspenseWrappers';
import { VehicleMobileCard } from './VehicleMobileCard';
import { Vehicle } from '../types/vehicles.types';
@@ -32,6 +36,31 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
onVehicleSelect
}) => {
const { data: vehicles, isLoading } = useVehicles();
const [_isPending, startTransition] = useTransition();
// React 19 optimistic updates
const {
optimisticVehicles,
isPending: isOptimisticPending
} = useOptimisticVehicles(vehicles || []);
// Enhanced search with transitions
const {
filteredVehicles,
updateVehicles
} = useVehicleSearch(optimisticVehicles);
// Update search when optimistic vehicles change
useEffect(() => {
updateVehicles(optimisticVehicles);
}, [optimisticVehicles, updateVehicles]);
const handleVehicleSelect = (vehicle: Vehicle) => {
// Use transition to avoid blocking UI during navigation
startTransition(() => {
onVehicleSelect?.(vehicle);
});
};
if (isLoading) {
return (
@@ -43,7 +72,7 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
);
}
if (!vehicles?.length) {
if (!optimisticVehicles.length) {
return (
<Box sx={{ pb: 10 }}>
<Section title="Vehicles">
@@ -61,19 +90,21 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
}
return (
<Box sx={{ pb: 10 }}>
<Section title="Vehicles">
<Grid container spacing={2}>
{vehicles.map((vehicle) => (
<Grid item xs={12} key={vehicle.id}>
<VehicleMobileCard
vehicle={vehicle}
onClick={() => onVehicleSelect?.(vehicle)}
/>
</Grid>
))}
</Grid>
</Section>
</Box>
<MobileVehiclesSuspense>
<Box sx={{ pb: 10 }}>
<Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}>
<Grid container spacing={2}>
{filteredVehicles.map((vehicle) => (
<Grid item xs={12} key={vehicle.id}>
<VehicleMobileCard
vehicle={vehicle}
onClick={() => handleVehicleSelect(vehicle)}
/>
</Grid>
))}
</Grid>
</Section>
</Box>
</MobileVehiclesSuspense>
);
};

View File

@@ -1,37 +1,75 @@
/**
* @ai-summary Main vehicles page with Material Design 3
* @ai-summary Main vehicles page with React 19 advanced features
* @ai-context Enhanced with Suspense, useOptimistic, and useTransition
*/
import React, { useState } from 'react';
import { Box, Typography, Grid, Button as MuiButton } from '@mui/material';
import React, { useState, useEffect, useTransition } from 'react';
import { Box, Typography, Grid, Button as MuiButton, TextField, IconButton } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useVehicles, useCreateVehicle, useDeleteVehicle } from '../hooks/useVehicles';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import { useVehicles } from '../hooks/useVehicles';
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
import { VehicleCard } from '../components/VehicleCard';
import { VehicleForm } from '../components/VehicleForm';
import { Card } from '../../../shared-minimal/components/Card';
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
import { useAppStore } from '../../../core/store';
import { useNavigate } from 'react-router-dom';
export const VehiclesPage: React.FC = () => {
const navigate = useNavigate();
const { data: vehicles, isLoading } = useVehicles();
const createVehicle = useCreateVehicle();
const deleteVehicle = useDeleteVehicle();
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
// React 19 optimistic updates and transitions
const {
optimisticVehicles,
isPending: isOptimisticPending,
optimisticCreateVehicle,
optimisticDeleteVehicle
} = useOptimisticVehicles(vehicles || []);
const {
searchQuery,
filteredVehicles,
isPending: isSearchPending,
handleSearch,
clearSearch,
updateVehicles
} = useVehicleSearch(optimisticVehicles);
const [isPending, startTransition] = useTransition();
const [showForm, setShowForm] = useState(false);
// Update search vehicles when optimistic vehicles change
useEffect(() => {
updateVehicles(optimisticVehicles);
}, [optimisticVehicles, updateVehicles]);
const handleSelectVehicle = (id: string) => {
setSelectedVehicle(id);
navigate(`/vehicles/${id}`);
// Use transition for navigation to avoid blocking UI
startTransition(() => {
setSelectedVehicle(id);
navigate(`/vehicles/${id}`);
});
};
const handleDelete = async (id: string) => {
if (confirm('Are you sure you want to delete this vehicle?')) {
await deleteVehicle.mutateAsync(id);
await optimisticDeleteVehicle(id);
}
};
const handleCreateVehicle = async (data: any) => {
await optimisticCreateVehicle(data);
// Use transition for UI state change
startTransition(() => {
setShowForm(false);
});
};
if (isLoading) {
return (
<Box sx={{
@@ -46,45 +84,77 @@ export const VehiclesPage: React.FC = () => {
}
return (
<Box sx={{ py: 2 }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 4
}}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
My Vehicles
</Typography>
{!showForm && (
<MuiButton
variant="contained"
startIcon={<AddIcon />}
onClick={() => setShowForm(true)}
sx={{ borderRadius: '999px' }}
>
Add Vehicle
</MuiButton>
<VehicleListSuspense>
<Box sx={{ py: 2 }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 4
}}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
My Vehicles
</Typography>
{!showForm && (
<MuiButton
variant="contained"
startIcon={<AddIcon />}
onClick={() => startTransition(() => setShowForm(true))}
sx={{ borderRadius: '999px' }}
disabled={isPending || isOptimisticPending}
>
Add Vehicle
</MuiButton>
)}
</Box>
{/* Search functionality */}
{vehicles && vehicles.length > 0 && (
<Box sx={{ mb: 3 }}>
<TextField
fullWidth
placeholder="Search vehicles by name, make, model, or VIN..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
InputProps={{
startAdornment: <SearchIcon sx={{ mr: 1, color: 'text.secondary' }} />,
endAdornment: searchQuery && (
<IconButton onClick={clearSearch} size="small">
<ClearIcon />
</IconButton>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: '999px',
backgroundColor: isSearchPending ? 'action.hover' : 'background.paper'
}
}}
/>
{searchQuery && (
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
{isSearchPending ? 'Searching...' : `Found ${filteredVehicles.length} vehicle(s)`}
</Typography>
)}
</Box>
)}
</Box>
{showForm && (
<Card className="mb-6">
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
Add New Vehicle
</Typography>
<VehicleForm
onSubmit={async (data) => {
await createVehicle.mutateAsync(data);
setShowForm(false);
}}
onCancel={() => setShowForm(false)}
loading={createVehicle.isPending}
/>
</Card>
<FormSuspense>
<Card className="mb-6">
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
Add New Vehicle
</Typography>
<VehicleForm
onSubmit={handleCreateVehicle}
onCancel={() => startTransition(() => setShowForm(false))}
loading={isOptimisticPending}
/>
</Card>
</FormSuspense>
)}
{vehicles?.length === 0 ? (
{optimisticVehicles.length === 0 ? (
<Card>
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography color="text.secondary" sx={{ mb: 3 }}>
@@ -94,8 +164,9 @@ export const VehiclesPage: React.FC = () => {
<MuiButton
variant="contained"
startIcon={<AddIcon />}
onClick={() => setShowForm(true)}
onClick={() => startTransition(() => setShowForm(true))}
sx={{ borderRadius: '999px' }}
disabled={isPending || isOptimisticPending}
>
Add Your First Vehicle
</MuiButton>
@@ -104,7 +175,7 @@ export const VehiclesPage: React.FC = () => {
</Card>
) : (
<Grid container spacing={3}>
{vehicles?.map((vehicle) => (
{filteredVehicles.map((vehicle) => (
<Grid item xs={12} md={6} lg={4} key={vehicle.id}>
<VehicleCard
vehicle={vehicle}
@@ -116,6 +187,7 @@ export const VehiclesPage: React.FC = () => {
))}
</Grid>
)}
</Box>
</Box>
</VehicleListSuspense>
);
};

View File

@@ -0,0 +1,46 @@
/**
* @ai-summary Skeleton loading component for mobile vehicles screen
* @ai-context React 19 Suspense fallback optimized for mobile interface
*/
import React from 'react';
import { Skeleton } from '@mui/material';
import { GlassCard } from '../mobile/GlassCard';
export const MobileVehiclesSkeleton: React.FC = () => {
return (
<div className="space-y-4">
{/* Header skeleton */}
<div className="flex justify-between items-center mb-6">
<Skeleton variant="text" width={120} height={32} />
<Skeleton variant="circular" width={40} height={40} />
</div>
{/* Mobile vehicle cards skeleton */}
{Array.from({ length: 4 }).map((_, index) => (
<GlassCard key={index}>
<div className="flex items-center space-x-4 p-4">
{/* Vehicle icon */}
<Skeleton variant="circular" width={48} height={48} />
<div className="flex-1 space-y-2">
{/* Vehicle name */}
<Skeleton variant="text" width="60%" height={20} />
{/* Vehicle details */}
<Skeleton variant="text" width="80%" height={16} />
<Skeleton variant="text" width="40%" height={16} />
</div>
{/* Action button */}
<Skeleton variant="circular" width={32} height={32} />
</div>
</GlassCard>
))}
{/* Add button skeleton */}
<div className="fixed bottom-20 right-4">
<Skeleton variant="circular" width={56} height={56} />
</div>
</div>
);
};

View File

@@ -0,0 +1,57 @@
/**
* @ai-summary Skeleton loading component for individual vehicle card
* @ai-context React 19 Suspense fallback for vehicle card loading
*/
import React from 'react';
import { Box, Skeleton } from '@mui/material';
export const VehicleCardSkeleton: React.FC = () => {
return (
<Box sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
p: 2,
height: '280px',
display: 'flex',
flexDirection: 'column'
}}>
{/* Vehicle image skeleton */}
<Skeleton
variant="rectangular"
width="100%"
height={120}
sx={{ mb: 2, borderRadius: 1, flexShrink: 0 }}
/>
{/* Vehicle name */}
<Skeleton
variant="text"
width="80%"
height={28}
sx={{ mb: 1 }}
/>
{/* Vehicle details */}
<Skeleton
variant="text"
width="60%"
height={20}
sx={{ mb: 1 }}
/>
<Skeleton
variant="text"
width="70%"
height={20}
sx={{ mb: 2, flex: 1 }}
/>
{/* Action buttons */}
<Box sx={{ display: 'flex', gap: 1, mt: 'auto' }}>
<Skeleton variant="rounded" width={70} height={32} />
<Skeleton variant="rounded" width={70} height={32} />
</Box>
</Box>
);
};

View File

@@ -0,0 +1,54 @@
/**
* @ai-summary Skeleton loading component for vehicle list
* @ai-context React 19 Suspense fallback component for better loading UX
*/
import React from 'react';
import { Box, Skeleton, Grid } from '@mui/material';
export const VehicleListSkeleton: React.FC = () => {
return (
<Box sx={{ py: 2 }}>
{/* Header skeleton */}
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 4
}}>
<Skeleton variant="text" width={200} height={40} />
<Skeleton variant="rounded" width={140} height={40} />
</Box>
{/* Vehicle cards skeleton */}
<Grid container spacing={3}>
{Array.from({ length: 6 }).map((_, index) => (
<Grid item xs={12} md={6} lg={4} key={index}>
<Box sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
p: 2
}}>
{/* Vehicle image skeleton */}
<Skeleton variant="rectangular" width="100%" height={120} sx={{ mb: 2, borderRadius: 1 }} />
{/* Vehicle name */}
<Skeleton variant="text" width="80%" height={24} sx={{ mb: 1 }} />
{/* Vehicle details */}
<Skeleton variant="text" width="60%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width="70%" height={20} sx={{ mb: 2 }} />
{/* Action buttons */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Skeleton variant="rounded" width={60} height={32} />
<Skeleton variant="rounded" width={60} height={32} />
</Box>
</Box>
</Grid>
))}
</Grid>
</Box>
);
};

View File

@@ -30,4 +30,54 @@ export default defineConfig({
'.motovaultpro.com'
],
},
build: {
rollupOptions: {
output: {
manualChunks: {
// React ecosystem
'react-vendor': ['react', 'react-dom'],
'react-router': ['react-router-dom'],
// UI library
'mui-core': ['@mui/material', '@mui/system'],
'mui-icons': ['@mui/icons-material'],
'emotion': ['@emotion/react', '@emotion/styled'],
// Authentication
'auth': ['@auth0/auth0-react'],
// Data fetching and state management
'data': ['@tanstack/react-query', 'zustand', 'axios'],
// Form handling
'forms': ['react-hook-form', '@hookform/resolvers', 'zod'],
// Utilities
'utils': ['date-fns', 'clsx'],
// Animation and UI
'animation': ['framer-motion', 'react-hot-toast']
},
},
},
chunkSizeWarningLimit: 600, // Increase slightly from 500kb
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // Remove console logs in production
drop_debugger: true,
pure_funcs: ['console.log', 'console.info', 'console.debug'],
},
mangle: {
safari10: true, // Ensure Safari 10 compatibility
},
},
sourcemap: false, // Disable sourcemaps in production for smaller bundles
cssMinify: true,
cssCodeSplit: true, // Split CSS into separate chunks
},
// Production optimizations
esbuild: {
drop: ['console', 'debugger'], // Additional cleanup
},
});