All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 38s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Backend: - Add VEHICLE_LIMITS configuration to feature-tiers.ts - Add getVehicleLimit, canAddVehicle helper functions - Implement transaction-based limit check with FOR UPDATE locking - Add VehicleLimitExceededError and 403 TIER_REQUIRED response - Add countByUserId to VehiclesRepository - Add comprehensive tests for all limit logic Frontend: - Add getResourceLimit, isAtResourceLimit to useTierAccess hook - Create VehicleLimitDialog component with mobile/desktop modes - Add useVehicleLimitCheck shared hook for limit state - Update VehiclesPage with limit checks and lock icon - Update VehiclesMobileScreen with limit checks - Add tests for VehicleLimitDialog Implements vehicle limits per tier (Free: 2, Pro: 5, Enterprise: unlimited) with race condition prevention and consistent UX across mobile/desktop. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
270 lines
9.1 KiB
TypeScript
270 lines
9.1 KiB
TypeScript
/**
|
|
* @ai-summary Main vehicles page with React 19 advanced features
|
|
* @ai-context Enhanced with Suspense, useOptimistic, and useTransition
|
|
*/
|
|
|
|
import React, { useState, useTransition, useMemo, useEffect } from 'react';
|
|
import { Box, Typography, Grid, Button as MuiButton, TextField, IconButton } from '@mui/material';
|
|
import AddIcon from '@mui/icons-material/Add';
|
|
import LockIcon from '@mui/icons-material/Lock';
|
|
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 { useVehicleLimitCheck } from '../hooks/useVehicleLimitCheck';
|
|
import { VehicleCard } from '../components/VehicleCard';
|
|
import { VehicleForm } from '../components/VehicleForm';
|
|
import { Card } from '../../../shared-minimal/components/Card';
|
|
import { VehicleLimitDialog } from '../../../shared-minimal/components';
|
|
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
|
|
import { useAppStore } from '../../../core/store';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { vehiclesApi } from '../api/vehicles.api';
|
|
|
|
export const VehiclesPage: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const queryClient = useQueryClient();
|
|
const { data: vehicles, isLoading } = useVehicles();
|
|
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
|
|
|
|
// Stable reference for empty array (prevents infinite loop when vehicles is undefined)
|
|
const safeVehicles = useMemo(
|
|
() => (Array.isArray(vehicles) ? vehicles : []),
|
|
[vehicles]
|
|
);
|
|
|
|
// React 19 optimistic updates and transitions
|
|
const {
|
|
optimisticVehicles,
|
|
isPending: isOptimisticPending,
|
|
optimisticCreateVehicle,
|
|
optimisticDeleteVehicle
|
|
} = useOptimisticVehicles(safeVehicles);
|
|
|
|
const {
|
|
searchQuery,
|
|
filteredVehicles,
|
|
isPending: isSearchPending,
|
|
handleSearch,
|
|
clearSearch
|
|
} = useVehicleSearch(optimisticVehicles);
|
|
|
|
const [isPending, startTransition] = useTransition();
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [stagedImageFile, setStagedImageFile] = useState<File | null>(null);
|
|
|
|
// Vehicle limit check
|
|
const {
|
|
isAtLimit,
|
|
limit,
|
|
tier,
|
|
showLimitDialog,
|
|
setShowLimitDialog,
|
|
} = useVehicleLimitCheck(safeVehicles.length);
|
|
|
|
// Auto-show form if navigated with showAddForm state (from dashboard)
|
|
useEffect(() => {
|
|
const state = location.state as { showAddForm?: boolean } | null;
|
|
if (state?.showAddForm) {
|
|
setShowForm(true);
|
|
// Clear the state to prevent re-triggering on refresh
|
|
navigate(location.pathname, { replace: true, state: {} });
|
|
}
|
|
}, [location.state, location.pathname, navigate]);
|
|
|
|
const handleSelectVehicle = (id: string) => {
|
|
// Use transition for navigation to avoid blocking UI
|
|
startTransition(() => {
|
|
const vehicle = optimisticVehicles.find(v => v.id === id);
|
|
setSelectedVehicle(vehicle || null);
|
|
navigate(`/garage/vehicles/${id}`);
|
|
});
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (confirm('Are you sure you want to delete this vehicle?')) {
|
|
await optimisticDeleteVehicle(id);
|
|
}
|
|
};
|
|
|
|
const handleCreateVehicle = async (data: any) => {
|
|
const newVehicle = await optimisticCreateVehicle(data);
|
|
|
|
console.log('[VehiclesPage] Vehicle created:', newVehicle?.id, 'stagedImageFile:', !!stagedImageFile);
|
|
|
|
// Upload staged image if one was selected during creation
|
|
if (stagedImageFile && newVehicle?.id) {
|
|
// Don't upload if ID is temporary (optimistic)
|
|
if (newVehicle.id.startsWith('temp-')) {
|
|
console.warn('[VehiclesPage] Cannot upload image - vehicle has temporary ID:', newVehicle.id);
|
|
} else {
|
|
try {
|
|
console.log('[VehiclesPage] Uploading image for vehicle:', newVehicle.id);
|
|
const updatedVehicle = await vehiclesApi.uploadImage(newVehicle.id, stagedImageFile);
|
|
console.log('[VehiclesPage] Image uploaded, updated vehicle:', updatedVehicle);
|
|
// Directly update the cache with the vehicle that has imageUrl
|
|
queryClient.setQueryData(['vehicles'], (old: typeof vehicles) => {
|
|
if (!old || !Array.isArray(old)) return old;
|
|
return old.map(v => v.id === updatedVehicle.id ? updatedVehicle : v);
|
|
});
|
|
} catch (err: any) {
|
|
console.error('[VehiclesPage] Failed to upload vehicle image:', {
|
|
error: err,
|
|
message: err?.message,
|
|
response: err?.response?.data,
|
|
status: err?.response?.status
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
setStagedImageFile(null);
|
|
// Use transition for UI state change
|
|
startTransition(() => {
|
|
setShowForm(false);
|
|
});
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Box sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: '50vh'
|
|
}}>
|
|
<Typography color="text.secondary">Loading vehicles...</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const handleAddVehicleClick = () => {
|
|
if (isAtLimit) {
|
|
setShowLimitDialog(true);
|
|
} else {
|
|
startTransition(() => setShowForm(true));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<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={isAtLimit ? <LockIcon /> : <AddIcon />}
|
|
onClick={handleAddVehicleClick}
|
|
sx={{ borderRadius: '999px' }}
|
|
disabled={isPending || isOptimisticPending}
|
|
>
|
|
Add Vehicle
|
|
</MuiButton>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Search functionality */}
|
|
{vehicles && Array.isArray(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>
|
|
)}
|
|
|
|
{showForm && (
|
|
<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}
|
|
onStagedImage={setStagedImageFile}
|
|
/>
|
|
</Card>
|
|
</FormSuspense>
|
|
)}
|
|
|
|
{optimisticVehicles.length === 0 ? (
|
|
<Card>
|
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
|
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
|
No vehicles added yet
|
|
</Typography>
|
|
{!showForm && (
|
|
<MuiButton
|
|
variant="contained"
|
|
startIcon={isAtLimit ? <LockIcon /> : <AddIcon />}
|
|
onClick={handleAddVehicleClick}
|
|
sx={{ borderRadius: '999px' }}
|
|
disabled={isPending || isOptimisticPending}
|
|
>
|
|
Add Your First Vehicle
|
|
</MuiButton>
|
|
)}
|
|
</Box>
|
|
</Card>
|
|
) : (
|
|
<Grid container spacing={3}>
|
|
{filteredVehicles.map((vehicle) => (
|
|
<Grid item xs={12} md={6} lg={4} key={vehicle.id}>
|
|
<VehicleCard
|
|
vehicle={vehicle}
|
|
onEdit={(v) => navigate(`/garage/vehicles/${v.id}?edit=true`)}
|
|
onDelete={handleDelete}
|
|
onSelect={handleSelectVehicle}
|
|
/>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
)}
|
|
|
|
{/* Vehicle Limit Dialog */}
|
|
<VehicleLimitDialog
|
|
open={showLimitDialog}
|
|
onClose={() => setShowLimitDialog(false)}
|
|
currentCount={safeVehicles.length}
|
|
limit={limit ?? 0}
|
|
currentTier={tier}
|
|
/>
|
|
</Box>
|
|
</VehicleListSuspense>
|
|
);
|
|
};
|