feat: Add tier-based vehicle limit enforcement (refs #23)
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>
This commit is contained in:
Eric Gullickson
2026-01-11 16:36:53 -06:00
parent dff743ca36
commit 20189a1d37
15 changed files with 1179 additions and 48 deletions

View File

@@ -6,14 +6,17 @@
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';
@@ -53,6 +56,15 @@ export const VehiclesPage: React.FC = () => {
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;
@@ -129,23 +141,31 @@ export const VehiclesPage: React.FC = () => {
);
}
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
<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))}
<MuiButton
variant="contained"
startIcon={isAtLimit ? <LockIcon /> : <AddIcon />}
onClick={handleAddVehicleClick}
sx={{ borderRadius: '999px' }}
disabled={isPending || isOptimisticPending}
>
@@ -208,10 +228,10 @@ export const VehiclesPage: React.FC = () => {
No vehicles added yet
</Typography>
{!showForm && (
<MuiButton
variant="contained"
startIcon={<AddIcon />}
onClick={() => startTransition(() => setShowForm(true))}
<MuiButton
variant="contained"
startIcon={isAtLimit ? <LockIcon /> : <AddIcon />}
onClick={handleAddVehicleClick}
sx={{ borderRadius: '999px' }}
disabled={isPending || isOptimisticPending}
>
@@ -234,6 +254,15 @@ export const VehiclesPage: React.FC = () => {
))}
</Grid>
)}
{/* Vehicle Limit Dialog */}
<VehicleLimitDialog
open={showLimitDialog}
onClose={() => setShowLimitDialog(false)}
currentCount={safeVehicles.length}
limit={limit ?? 0}
currentTier={tier}
/>
</Box>
</VehicleListSuspense>
);