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>
154 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}; |