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
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:
23
frontend/src/features/vehicles/hooks/useVehicleLimitCheck.ts
Normal file
23
frontend/src/features/vehicles/hooks/useVehicleLimitCheck.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @ai-summary Hook for checking vehicle limit and managing limit dialog
|
||||
* @ai-context Shared between desktop and mobile vehicle pages
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
||||
|
||||
export const useVehicleLimitCheck = (vehicleCount: number) => {
|
||||
const { tier, isAtResourceLimit, getResourceLimit } = useTierAccess();
|
||||
const [showLimitDialog, setShowLimitDialog] = useState(false);
|
||||
|
||||
const isAtLimit = isAtResourceLimit('vehicles', vehicleCount);
|
||||
const limit = getResourceLimit('vehicles');
|
||||
|
||||
return {
|
||||
isAtLimit,
|
||||
limit,
|
||||
tier,
|
||||
showLimitDialog,
|
||||
setShowLimitDialog,
|
||||
};
|
||||
};
|
||||
@@ -6,10 +6,13 @@
|
||||
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';
|
||||
|
||||
@@ -56,6 +59,15 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
// 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(() => {
|
||||
@@ -63,6 +75,14 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddVehicleClick = () => {
|
||||
if (isAtLimit) {
|
||||
setShowLimitDialog(true);
|
||||
} else {
|
||||
onAddVehicle?.();
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ pb: 10 }}>
|
||||
@@ -92,8 +112,8 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => onAddVehicle?.()}
|
||||
startIcon={isAtLimit ? <LockIcon /> : <AddIcon />}
|
||||
onClick={handleAddVehicleClick}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
Add Vehicle
|
||||
@@ -119,6 +139,15 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
))}
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
{/* Vehicle Limit Dialog */}
|
||||
<VehicleLimitDialog
|
||||
open={showLimitDialog}
|
||||
onClose={() => setShowLimitDialog(false)}
|
||||
currentCount={safeVehicles.length}
|
||||
limit={limit ?? 0}
|
||||
currentTier={tier}
|
||||
/>
|
||||
</Box>
|
||||
</MobileVehiclesSuspense>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user