Files
motovaultpro/frontend/src/features/vehicles/pages/VehiclesPage.tsx
Eric Gullickson 20189a1d37
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
feat: Add tier-based vehicle limit enforcement (refs #23)
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>
2026-01-11 16:36:53 -06:00

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>
);
};