Modernization Project Complete. Updated to latest versions of frameworks.
This commit is contained in:
@@ -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>
|
||||
|
||||
99
frontend/src/components/SuspenseWrappers.tsx
Normal file
99
frontend/src/components/SuspenseWrappers.tsx
Normal 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>
|
||||
);
|
||||
136
frontend/src/features/vehicles/hooks/useOptimisticVehicles.ts
Normal file
136
frontend/src/features/vehicles/hooks/useOptimisticVehicles.ts
Normal 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
|
||||
};
|
||||
}
|
||||
191
frontend/src/features/vehicles/hooks/useVehicleTransitions.ts
Normal file
191
frontend/src/features/vehicles/hooks/useVehicleTransitions.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user