Files
motovaultpro/frontend/src/features/vehicles/mobile/VehiclesMobileScreen.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

154 lines
4.6 KiB
TypeScript

/**
* @ai-summary Mobile vehicles screen with React 19 enhancements
* @ai-context Enhanced with Suspense, optimistic updates, and transitions
*/
import React, { useTransition, useMemo } from 'react';
import { Box, Typography, Grid, Button } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import LockIcon from '@mui/icons-material/Lock';
import { useVehicles } from '../hooks/useVehicles';
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
import { useVehicleLimitCheck } from '../hooks/useVehicleLimitCheck';
import { MobileVehiclesSuspense } from '../../../components/SuspenseWrappers';
import { VehicleLimitDialog } from '../../../shared-minimal/components';
import { VehicleMobileCard } from './VehicleMobileCard';
import { Vehicle } from '../types/vehicles.types';
interface VehiclesMobileScreenProps {
onVehicleSelect?: (vehicle: Vehicle) => void;
onAddVehicle?: () => void;
}
const Section: React.FC<{ title: string; children: React.ReactNode; right?: React.ReactNode }> = ({
title,
children,
right
}) => (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{title}
</Typography>
{right}
</Box>
{children}
</Box>
);
export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
onVehicleSelect,
onAddVehicle
}) => {
const { data: vehicles, isLoading } = useVehicles();
const [_isPending, startTransition] = useTransition();
// Stable reference for empty array (prevents infinite loop when vehicles is undefined)
const safeVehicles = useMemo(
() => (Array.isArray(vehicles) ? vehicles : []),
[vehicles]
);
// React 19 optimistic updates
const {
optimisticVehicles,
isPending: isOptimisticPending
} = useOptimisticVehicles(safeVehicles);
// Enhanced search with transitions (auto-syncs when vehicles change)
const { filteredVehicles } = useVehicleSearch(optimisticVehicles);
// Vehicle limit check
const {
isAtLimit,
limit,
tier,
showLimitDialog,
setShowLimitDialog,
} = useVehicleLimitCheck(safeVehicles.length);
const handleVehicleSelect = (vehicle: Vehicle) => {
// Use transition to avoid blocking UI during navigation
startTransition(() => {
onVehicleSelect?.(vehicle);
});
};
const handleAddVehicleClick = () => {
if (isAtLimit) {
setShowLimitDialog(true);
} else {
onAddVehicle?.();
}
};
if (isLoading) {
return (
<Box sx={{ pb: 10 }}>
<Box sx={{ textAlign: 'center', py: 12 }}>
<Typography color="text.secondary" sx={{ mb: 2 }}>
Loading your vehicles...
</Typography>
<Typography variant="caption" color="text.secondary">
Please wait a moment
</Typography>
</Box>
</Box>
);
}
if (!optimisticVehicles.length) {
return (
<Box sx={{ pb: 10 }}>
<Section title="Vehicles">
<Box sx={{ textAlign: 'center', py: 12 }}>
<Typography color="text.secondary" sx={{ mb: 2 }}>
No vehicles added yet
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 3 }}>
Add your first vehicle to get started
</Typography>
<Button
variant="contained"
color="primary"
startIcon={isAtLimit ? <LockIcon /> : <AddIcon />}
onClick={handleAddVehicleClick}
sx={{ minWidth: 160 }}
>
Add Vehicle
</Button>
</Box>
</Section>
</Box>
);
}
return (
<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>
{/* Vehicle Limit Dialog */}
<VehicleLimitDialog
open={showLimitDialog}
onClose={() => setShowLimitDialog(false)}
currentCount={safeVehicles.length}
limit={limit ?? 0}
currentTier={tier}
/>
</Box>
</MobileVehiclesSuspense>
);
};