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:
@@ -76,3 +76,75 @@ export function getFeatureConfig(featureKey: string): FeatureConfig | undefined
|
|||||||
export function getAllFeatureConfigs(): Record<string, FeatureConfig> {
|
export function getAllFeatureConfigs(): Record<string, FeatureConfig> {
|
||||||
return { ...FEATURE_TIERS };
|
return { ...FEATURE_TIERS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vehicle limits per tier
|
||||||
|
// null indicates unlimited (enterprise tier)
|
||||||
|
export const VEHICLE_LIMITS: Record<SubscriptionTier, number | null> = {
|
||||||
|
free: 2,
|
||||||
|
pro: 5,
|
||||||
|
enterprise: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vehicle limits vary by subscription tier and must be queryable
|
||||||
|
* at runtime for both backend enforcement and frontend UI state.
|
||||||
|
*
|
||||||
|
* @param tier - User's subscription tier
|
||||||
|
* @returns Maximum vehicles allowed, or null for unlimited (enterprise tier)
|
||||||
|
*/
|
||||||
|
export function getVehicleLimit(tier: SubscriptionTier): number | null {
|
||||||
|
return VEHICLE_LIMITS[tier] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user can add another vehicle based on their tier and current count.
|
||||||
|
*
|
||||||
|
* @param tier - User's subscription tier
|
||||||
|
* @param currentCount - Number of vehicles user currently has
|
||||||
|
* @returns true if user can add another vehicle, false if at/over limit
|
||||||
|
*/
|
||||||
|
export function canAddVehicle(tier: SubscriptionTier, currentCount: number): boolean {
|
||||||
|
const limit = getVehicleLimit(tier);
|
||||||
|
// null limit means unlimited (enterprise)
|
||||||
|
if (limit === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return currentCount < limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vehicle limit configuration with upgrade prompt.
|
||||||
|
* Structure supports additional resource types in the future.
|
||||||
|
*/
|
||||||
|
export interface VehicleLimitConfig {
|
||||||
|
limit: number | null;
|
||||||
|
tier: SubscriptionTier;
|
||||||
|
upgradePrompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get vehicle limit configuration with upgrade prompt for a tier.
|
||||||
|
*
|
||||||
|
* @param tier - User's subscription tier
|
||||||
|
* @returns Configuration with limit and upgrade prompt
|
||||||
|
*/
|
||||||
|
export function getVehicleLimitConfig(tier: SubscriptionTier): VehicleLimitConfig {
|
||||||
|
const limit = getVehicleLimit(tier);
|
||||||
|
|
||||||
|
const defaultPrompt = 'Upgrade to access additional vehicles.';
|
||||||
|
|
||||||
|
let upgradePrompt: string;
|
||||||
|
if (tier === 'free') {
|
||||||
|
upgradePrompt = 'Free tier is limited to 2 vehicles. Upgrade to Pro for up to 5 vehicles, or Enterprise for unlimited.';
|
||||||
|
} else if (tier === 'pro') {
|
||||||
|
upgradePrompt = 'Pro tier is limited to 5 vehicles. Upgrade to Enterprise for unlimited vehicles.';
|
||||||
|
} else {
|
||||||
|
upgradePrompt = defaultPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
limit,
|
||||||
|
tier,
|
||||||
|
upgradePrompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
TIER_LEVELS,
|
TIER_LEVELS,
|
||||||
FEATURE_TIERS,
|
FEATURE_TIERS,
|
||||||
|
VEHICLE_LIMITS,
|
||||||
getTierLevel,
|
getTierLevel,
|
||||||
canAccessFeature,
|
canAccessFeature,
|
||||||
getRequiredTier,
|
getRequiredTier,
|
||||||
getFeatureConfig,
|
getFeatureConfig,
|
||||||
getAllFeatureConfigs,
|
getAllFeatureConfigs,
|
||||||
|
getVehicleLimit,
|
||||||
|
canAddVehicle,
|
||||||
|
getVehicleLimitConfig,
|
||||||
} from '../feature-tiers';
|
} from '../feature-tiers';
|
||||||
|
|
||||||
describe('feature-tiers', () => {
|
describe('feature-tiers', () => {
|
||||||
@@ -101,4 +105,97 @@ describe('feature-tiers', () => {
|
|||||||
expect(FEATURE_TIERS['test' as keyof typeof FEATURE_TIERS]).toBeUndefined();
|
expect(FEATURE_TIERS['test' as keyof typeof FEATURE_TIERS]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('VEHICLE_LIMITS', () => {
|
||||||
|
it('defines correct limits for each tier', () => {
|
||||||
|
expect(VEHICLE_LIMITS.free).toBe(2);
|
||||||
|
expect(VEHICLE_LIMITS.pro).toBe(5);
|
||||||
|
expect(VEHICLE_LIMITS.enterprise).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVehicleLimit', () => {
|
||||||
|
it('returns 2 for free tier', () => {
|
||||||
|
expect(getVehicleLimit('free')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 5 for pro tier', () => {
|
||||||
|
expect(getVehicleLimit('pro')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for enterprise tier (unlimited)', () => {
|
||||||
|
expect(getVehicleLimit('enterprise')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canAddVehicle', () => {
|
||||||
|
describe('free tier (limit 2)', () => {
|
||||||
|
it('returns true when below limit', () => {
|
||||||
|
expect(canAddVehicle('free', 0)).toBe(true);
|
||||||
|
expect(canAddVehicle('free', 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when at limit', () => {
|
||||||
|
expect(canAddVehicle('free', 2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when over limit', () => {
|
||||||
|
expect(canAddVehicle('free', 3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pro tier (limit 5)', () => {
|
||||||
|
it('returns true when below limit', () => {
|
||||||
|
expect(canAddVehicle('pro', 0)).toBe(true);
|
||||||
|
expect(canAddVehicle('pro', 4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when at limit', () => {
|
||||||
|
expect(canAddVehicle('pro', 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when over limit', () => {
|
||||||
|
expect(canAddVehicle('pro', 6)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enterprise tier (unlimited)', () => {
|
||||||
|
it('always returns true regardless of count', () => {
|
||||||
|
expect(canAddVehicle('enterprise', 0)).toBe(true);
|
||||||
|
expect(canAddVehicle('enterprise', 100)).toBe(true);
|
||||||
|
expect(canAddVehicle('enterprise', 999999)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVehicleLimitConfig', () => {
|
||||||
|
it('returns correct config for free tier', () => {
|
||||||
|
const config = getVehicleLimitConfig('free');
|
||||||
|
expect(config.limit).toBe(2);
|
||||||
|
expect(config.tier).toBe('free');
|
||||||
|
expect(config.upgradePrompt).toContain('Free tier is limited to 2 vehicles');
|
||||||
|
expect(config.upgradePrompt).toContain('Pro');
|
||||||
|
expect(config.upgradePrompt).toContain('Enterprise');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct config for pro tier', () => {
|
||||||
|
const config = getVehicleLimitConfig('pro');
|
||||||
|
expect(config.limit).toBe(5);
|
||||||
|
expect(config.tier).toBe('pro');
|
||||||
|
expect(config.upgradePrompt).toContain('Pro tier is limited to 5 vehicles');
|
||||||
|
expect(config.upgradePrompt).toContain('Enterprise');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct config for enterprise tier', () => {
|
||||||
|
const config = getVehicleLimitConfig('enterprise');
|
||||||
|
expect(config.limit).toBeNull();
|
||||||
|
expect(config.tier).toBe('enterprise');
|
||||||
|
expect(config.upgradePrompt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides default upgradePrompt fallback', () => {
|
||||||
|
const config = getVehicleLimitConfig('enterprise');
|
||||||
|
expect(config.upgradePrompt).toBe('Upgrade to access additional vehicles.');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { VehiclesService } from '../domain/vehicles.service';
|
import { VehiclesService, VehicleLimitExceededError } from '../domain/vehicles.service';
|
||||||
import { VehiclesRepository } from '../data/vehicles.repository';
|
import { VehiclesRepository } from '../data/vehicles.repository';
|
||||||
import { pool } from '../../../core/config/database';
|
import { pool } from '../../../core/config/database';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
@@ -21,7 +21,7 @@ export class VehiclesController {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const repository = new VehiclesRepository(pool);
|
const repository = new VehiclesRepository(pool);
|
||||||
this.vehiclesService = new VehiclesService(repository);
|
this.vehiclesService = new VehiclesService(repository, pool);
|
||||||
this.nhtsaClient = new NHTSAClient(pool);
|
this.nhtsaClient = new NHTSAClient(pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,25 +62,40 @@ export class VehiclesController {
|
|||||||
|
|
||||||
const userId = (request as any).user.sub;
|
const userId = (request as any).user.sub;
|
||||||
const vehicle = await this.vehiclesService.createVehicle(request.body, userId);
|
const vehicle = await this.vehiclesService.createVehicle(request.body, userId);
|
||||||
|
|
||||||
return reply.code(201).send(vehicle);
|
return reply.code(201).send(vehicle);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error creating vehicle', { error, userId: (request as any).user?.sub });
|
logger.error('Error creating vehicle', { error, userId: (request as any).user?.sub });
|
||||||
|
|
||||||
|
if (error instanceof VehicleLimitExceededError) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'TIER_REQUIRED',
|
||||||
|
requiredTier: error.tier === 'free' ? 'pro' : 'enterprise',
|
||||||
|
currentTier: error.tier,
|
||||||
|
feature: 'vehicle.addBeyondLimit',
|
||||||
|
featureName: 'Additional Vehicles',
|
||||||
|
upgradePrompt: error.upgradePrompt,
|
||||||
|
context: {
|
||||||
|
limit: error.limit,
|
||||||
|
count: error.currentCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (error.message === 'Invalid VIN format') {
|
if (error.message === 'Invalid VIN format') {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: error.message
|
message: error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.message === 'Vehicle with this VIN already exists') {
|
if (error.message === 'Vehicle with this VIN already exists') {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: error.message
|
message: error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to create vehicle'
|
message: 'Failed to create vehicle'
|
||||||
|
|||||||
@@ -47,11 +47,21 @@ export class VehiclesRepository {
|
|||||||
WHERE user_id = $1 AND is_active = true
|
WHERE user_id = $1 AND is_active = true
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await this.pool.query(query, [userId]);
|
const result = await this.pool.query(query, [userId]);
|
||||||
return result.rows.map(row => this.mapRow(row));
|
return result.rows.map(row => this.mapRow(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countByUserId(userId: string): Promise<number> {
|
||||||
|
const query = `
|
||||||
|
SELECT COUNT(*) as count FROM vehicles
|
||||||
|
WHERE user_id = $1 AND is_active = true
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.pool.query(query, [userId]);
|
||||||
|
return parseInt(result.rows[0].count, 10);
|
||||||
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Vehicle | null> {
|
async findById(id: string): Promise<Vehicle | null> {
|
||||||
const query = 'SELECT * FROM vehicles WHERE id = $1';
|
const query = 'SELECT * FROM vehicles WHERE id = $1';
|
||||||
const result = await this.pool.query(query, [id]);
|
const result = await this.pool.query(query, [id]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* @ai-context Handles VIN decoding, caching, and business rules
|
* @ai-context Handles VIN decoding, caching, and business rules
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
import { VehiclesRepository } from '../data/vehicles.repository';
|
import { VehiclesRepository } from '../data/vehicles.repository';
|
||||||
import {
|
import {
|
||||||
Vehicle,
|
Vehicle,
|
||||||
@@ -21,13 +22,33 @@ import { normalizeMakeName, normalizeModelName } from './name-normalizer';
|
|||||||
import { getVehicleDataService, getPool } from '../../platform';
|
import { getVehicleDataService, getPool } from '../../platform';
|
||||||
import { auditLogService } from '../../audit-log';
|
import { auditLogService } from '../../audit-log';
|
||||||
import { NHTSAClient, NHTSADecodeResponse, DecodedVehicleData, MatchedField } from '../external/nhtsa';
|
import { NHTSAClient, NHTSADecodeResponse, DecodedVehicleData, MatchedField } from '../external/nhtsa';
|
||||||
|
import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers';
|
||||||
|
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||||
|
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
|
||||||
|
|
||||||
|
export class VehicleLimitExceededError extends Error {
|
||||||
|
constructor(
|
||||||
|
public tier: SubscriptionTier,
|
||||||
|
public currentCount: number,
|
||||||
|
public limit: number,
|
||||||
|
public upgradePrompt: string
|
||||||
|
) {
|
||||||
|
super('Vehicle limit exceeded');
|
||||||
|
this.name = 'VehicleLimitExceededError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class VehiclesService {
|
export class VehiclesService {
|
||||||
private readonly cachePrefix = 'vehicles';
|
private readonly cachePrefix = 'vehicles';
|
||||||
private readonly listCacheTTL = 300; // 5 minutes
|
private readonly listCacheTTL = 300; // 5 minutes
|
||||||
|
private userProfileRepository: UserProfileRepository;
|
||||||
|
|
||||||
constructor(private repository: VehiclesRepository) {
|
constructor(
|
||||||
|
private repository: VehiclesRepository,
|
||||||
|
private pool: Pool
|
||||||
|
) {
|
||||||
// VIN decode service is now provided by platform feature
|
// VIN decode service is now provided by platform feature
|
||||||
|
this.userProfileRepository = new UserProfileRepository(pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
|
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
|
||||||
@@ -52,29 +73,123 @@ export class VehiclesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create vehicle with user-provided data
|
// Get user's tier for limit enforcement
|
||||||
const vehicle = await this.repository.create({
|
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
|
||||||
...data,
|
if (!userProfile) {
|
||||||
userId,
|
throw new Error('User profile not found');
|
||||||
make: data.make ? normalizeMakeName(data.make) : undefined,
|
}
|
||||||
model: data.model ? normalizeModelName(data.model) : undefined,
|
const userTier = userProfile.subscriptionTier;
|
||||||
});
|
|
||||||
|
|
||||||
// Invalidate user's vehicle list cache
|
// Tier limit enforcement with transaction + FOR UPDATE locking to prevent race condition
|
||||||
await this.invalidateUserCache(userId);
|
const client = await this.pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Log vehicle creation to unified audit log
|
// Lock user's vehicle rows and get count
|
||||||
const vehicleDesc = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ');
|
const countResult = await client.query(
|
||||||
await auditLogService.info(
|
'SELECT COUNT(*) as count FROM vehicles WHERE user_id = $1 AND is_active = true FOR UPDATE',
|
||||||
'vehicle',
|
[userId]
|
||||||
userId,
|
);
|
||||||
`Vehicle created: ${vehicleDesc || vehicle.id}`,
|
const currentCount = parseInt(countResult.rows[0].count, 10);
|
||||||
'vehicle',
|
|
||||||
vehicle.id,
|
|
||||||
{ vin: vehicle.vin, make: vehicle.make, model: vehicle.model, year: vehicle.year }
|
|
||||||
).catch(err => logger.error('Failed to log vehicle create audit event', { error: err }));
|
|
||||||
|
|
||||||
return this.toResponse(vehicle);
|
// Check if user can add another vehicle
|
||||||
|
if (!canAddVehicle(userTier, currentCount)) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
const limitConfig = getVehicleLimitConfig(userTier);
|
||||||
|
throw new VehicleLimitExceededError(
|
||||||
|
userTier,
|
||||||
|
currentCount,
|
||||||
|
limitConfig.limit!,
|
||||||
|
limitConfig.upgradePrompt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create vehicle with user-provided data (within transaction)
|
||||||
|
const query = `
|
||||||
|
INSERT INTO vehicles (
|
||||||
|
user_id, vin, make, model, year,
|
||||||
|
engine, transmission, trim_level, drive_type, fuel_type,
|
||||||
|
nickname, color, license_plate, odometer_reading
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const values = [
|
||||||
|
userId,
|
||||||
|
(data.vin && data.vin.trim().length > 0) ? data.vin.trim() : null,
|
||||||
|
data.make ? normalizeMakeName(data.make) : null,
|
||||||
|
data.model ? normalizeModelName(data.model) : null,
|
||||||
|
data.year,
|
||||||
|
data.engine,
|
||||||
|
data.transmission,
|
||||||
|
data.trimLevel,
|
||||||
|
data.driveType,
|
||||||
|
data.fuelType,
|
||||||
|
data.nickname,
|
||||||
|
data.color,
|
||||||
|
data.licensePlate,
|
||||||
|
data.odometerReading || 0
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await client.query(query, values);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
const vehicle = this.mapVehicleRow(result.rows[0]);
|
||||||
|
|
||||||
|
// Invalidate user's vehicle list cache
|
||||||
|
await this.invalidateUserCache(userId);
|
||||||
|
|
||||||
|
// Log vehicle creation to unified audit log
|
||||||
|
const vehicleDesc = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ');
|
||||||
|
await auditLogService.info(
|
||||||
|
'vehicle',
|
||||||
|
userId,
|
||||||
|
`Vehicle created: ${vehicleDesc || vehicle.id}`,
|
||||||
|
'vehicle',
|
||||||
|
vehicle.id,
|
||||||
|
{ vin: vehicle.vin, make: vehicle.make, model: vehicle.model, year: vehicle.year }
|
||||||
|
).catch(err => logger.error('Failed to log vehicle create audit event', { error: err }));
|
||||||
|
|
||||||
|
return this.toResponse(vehicle);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map database row to Vehicle domain object
|
||||||
|
*/
|
||||||
|
private mapVehicleRow(row: any): Vehicle {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
vin: row.vin,
|
||||||
|
make: row.make,
|
||||||
|
model: row.model,
|
||||||
|
year: row.year,
|
||||||
|
engine: row.engine,
|
||||||
|
transmission: row.transmission,
|
||||||
|
trimLevel: row.trim_level,
|
||||||
|
driveType: row.drive_type,
|
||||||
|
fuelType: row.fuel_type,
|
||||||
|
nickname: row.nickname,
|
||||||
|
color: row.color,
|
||||||
|
licensePlate: row.license_plate,
|
||||||
|
odometerReading: row.odometer_reading,
|
||||||
|
isActive: row.is_active,
|
||||||
|
deletedAt: row.deleted_at,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
imageStorageBucket: row.image_storage_bucket,
|
||||||
|
imageStorageKey: row.image_storage_key,
|
||||||
|
imageFileName: row.image_file_name,
|
||||||
|
imageContentType: row.image_content_type,
|
||||||
|
imageFileSize: row.image_file_size,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserVehicles(userId: string): Promise<VehicleResponse[]> {
|
async getUserVehicles(userId: string): Promise<VehicleResponse[]> {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { app } from '../../../../app';
|
|
||||||
import pool from '../../../../core/config/database';
|
import pool from '../../../../core/config/database';
|
||||||
import { cacheService } from '../../../../core/config/redis';
|
import { cacheService } from '../../../../core/config/redis';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
@@ -13,15 +12,19 @@ import fastifyPlugin from 'fastify-plugin';
|
|||||||
|
|
||||||
// Mock auth plugin to bypass JWT validation in tests
|
// Mock auth plugin to bypass JWT validation in tests
|
||||||
jest.mock('../../../../core/plugins/auth.plugin', () => {
|
jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||||
|
const fp = require('fastify-plugin');
|
||||||
return {
|
return {
|
||||||
default: fastifyPlugin(async function(fastify) {
|
default: fp(async function(fastify: any) {
|
||||||
fastify.decorate('authenticate', async function(request, _reply) {
|
fastify.decorate('authenticate', async function(request: any, _reply: any) {
|
||||||
request.user = { sub: 'test-user-123' };
|
request.user = { sub: 'test-user-123' };
|
||||||
});
|
});
|
||||||
}, { name: 'auth-plugin' })
|
}, { name: 'auth-plugin' })
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Import app after mocking
|
||||||
|
import app from '../../../../app';
|
||||||
|
|
||||||
|
|
||||||
describe('Vehicles Integration Tests', () => {
|
describe('Vehicles Integration Tests', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -263,7 +266,7 @@ describe('Vehicles Integration Tests', () => {
|
|||||||
|
|
||||||
it('should return 404 for non-existent vehicle', async () => {
|
it('should return 404 for non-existent vehicle', async () => {
|
||||||
const fakeId = '550e8400-e29b-41d4-a716-446655440000';
|
const fakeId = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.delete(`/api/vehicles/${fakeId}`)
|
.delete(`/api/vehicles/${fakeId}`)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
@@ -271,4 +274,183 @@ describe('Vehicles Integration Tests', () => {
|
|||||||
expect(response.body.error).toBe('Vehicle not found');
|
expect(response.body.error).toBe('Vehicle not found');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Vehicle Limit Enforcement', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Ensure user_profiles table exists and has test user
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
auth0_sub VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
subscription_tier VARCHAR(50) NOT NULL DEFAULT 'free',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create or update test user with free tier
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO user_profiles (auth0_sub, email, subscription_tier)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (auth0_sub) DO UPDATE SET subscription_tier = EXCLUDED.subscription_tier
|
||||||
|
`, ['test-user-123', 'test@example.com', 'free']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce free tier limit (2 vehicles)', async () => {
|
||||||
|
// Create 2 vehicles (at limit)
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO vehicles (user_id, year, make, model, license_plate)
|
||||||
|
VALUES
|
||||||
|
($1, 2020, 'Honda', 'Civic', 'ABC123'),
|
||||||
|
($1, 2021, 'Toyota', 'Camry', 'XYZ789')
|
||||||
|
`, ['test-user-123']);
|
||||||
|
|
||||||
|
// Attempt to create a 3rd vehicle
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/vehicles')
|
||||||
|
.send({
|
||||||
|
year: 2022,
|
||||||
|
make: 'Ford',
|
||||||
|
model: 'F-150',
|
||||||
|
licensePlate: 'TEST123'
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
error: 'TIER_REQUIRED',
|
||||||
|
currentTier: 'free',
|
||||||
|
requiredTier: 'pro',
|
||||||
|
feature: 'vehicle.addBeyondLimit',
|
||||||
|
context: {
|
||||||
|
limit: 2,
|
||||||
|
count: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(response.body.upgradePrompt).toContain('Free tier is limited to 2 vehicles');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow free tier user to add vehicle when below limit', async () => {
|
||||||
|
// Create 1 vehicle (below limit)
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO vehicles (user_id, year, make, model, license_plate)
|
||||||
|
VALUES ($1, 2020, 'Honda', 'Civic', 'ABC123')
|
||||||
|
`, ['test-user-123']);
|
||||||
|
|
||||||
|
// Should be able to add 2nd vehicle
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/vehicles')
|
||||||
|
.send({
|
||||||
|
year: 2021,
|
||||||
|
make: 'Toyota',
|
||||||
|
model: 'Camry',
|
||||||
|
licensePlate: 'XYZ789'
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
year: 2021,
|
||||||
|
make: 'Toyota',
|
||||||
|
model: 'Camry'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce pro tier limit (5 vehicles)', async () => {
|
||||||
|
// Update user to pro tier
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE user_profiles SET subscription_tier = 'pro' WHERE auth0_sub = $1
|
||||||
|
`, ['test-user-123']);
|
||||||
|
|
||||||
|
// Create 5 vehicles (at limit)
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO vehicles (user_id, year, make, model, license_plate)
|
||||||
|
VALUES ($1, 2020, 'Honda', 'Civic', $2)
|
||||||
|
`, ['test-user-123', `PLATE${i}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to create a 6th vehicle
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/vehicles')
|
||||||
|
.send({
|
||||||
|
year: 2022,
|
||||||
|
make: 'Ford',
|
||||||
|
model: 'F-150',
|
||||||
|
licensePlate: 'TEST123'
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
error: 'TIER_REQUIRED',
|
||||||
|
currentTier: 'pro',
|
||||||
|
requiredTier: 'enterprise',
|
||||||
|
context: {
|
||||||
|
limit: 5,
|
||||||
|
count: 5
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow enterprise tier unlimited vehicles', async () => {
|
||||||
|
// Update user to enterprise tier
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE user_profiles SET subscription_tier = 'enterprise' WHERE auth0_sub = $1
|
||||||
|
`, ['test-user-123']);
|
||||||
|
|
||||||
|
// Create 10 vehicles (well beyond free/pro limits)
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO vehicles (user_id, year, make, model, license_plate)
|
||||||
|
VALUES ($1, 2020, 'Honda', 'Civic', $2)
|
||||||
|
`, ['test-user-123', `PLATE${i}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should still be able to add another vehicle
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/vehicles')
|
||||||
|
.send({
|
||||||
|
year: 2022,
|
||||||
|
make: 'Ford',
|
||||||
|
model: 'F-150',
|
||||||
|
licensePlate: 'ENTERPRISE1'
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
year: 2022,
|
||||||
|
make: 'Ford',
|
||||||
|
model: 'F-150'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only count active vehicles towards limit', async () => {
|
||||||
|
// Create 1 active vehicle and 1 deleted vehicle
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO vehicles (user_id, year, make, model, license_plate)
|
||||||
|
VALUES ($1, 2020, 'Honda', 'Civic', 'ABC123')
|
||||||
|
`, ['test-user-123']);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO vehicles (user_id, year, make, model, license_plate, is_active, deleted_at)
|
||||||
|
VALUES ($1, 2019, 'Toyota', 'Corolla', 'OLD123', false, NOW())
|
||||||
|
`, ['test-user-123']);
|
||||||
|
|
||||||
|
// Should be able to add 2nd active vehicle (deleted one doesn't count)
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/vehicles')
|
||||||
|
.send({
|
||||||
|
year: 2021,
|
||||||
|
make: 'Toyota',
|
||||||
|
model: 'Camry',
|
||||||
|
licensePlate: 'XYZ789'
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
year: 2021,
|
||||||
|
make: 'Toyota'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Unit tests for VehiclesRepository
|
||||||
|
* @ai-context Tests repository data access methods with mocked database
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { VehiclesRepository } from '../../data/vehicles.repository';
|
||||||
|
|
||||||
|
describe('VehiclesRepository', () => {
|
||||||
|
let pool: Pool;
|
||||||
|
let repository: VehiclesRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pool = {
|
||||||
|
query: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
repository = new VehiclesRepository(pool);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('countByUserId', () => {
|
||||||
|
it('returns accurate count of active vehicles', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
rows: [{ count: '3' }],
|
||||||
|
};
|
||||||
|
(pool.query as jest.Mock).mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
const count = await repository.countByUserId('user-123');
|
||||||
|
|
||||||
|
expect(count).toBe(3);
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('SELECT COUNT(*) as count FROM vehicles'),
|
||||||
|
['user-123']
|
||||||
|
);
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('is_active = true'),
|
||||||
|
['user-123']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for user with no vehicles', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
rows: [{ count: '0' }],
|
||||||
|
};
|
||||||
|
(pool.query as jest.Mock).mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
const count = await repository.countByUserId('user-empty');
|
||||||
|
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only counts active vehicles (excludes deleted)', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
rows: [{ count: '2' }],
|
||||||
|
};
|
||||||
|
(pool.query as jest.Mock).mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
const count = await repository.countByUserId('user-with-deleted');
|
||||||
|
|
||||||
|
expect(count).toBe(2);
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('is_active = true'),
|
||||||
|
['user-with-deleted']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,10 +53,19 @@ describe('VehiclesService', () => {
|
|||||||
findByUserAndVIN: jest.fn(),
|
findByUserAndVIN: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
softDelete: jest.fn(),
|
softDelete: jest.fn(),
|
||||||
|
countByUserId: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const mockPool = {
|
||||||
|
query: jest.fn(),
|
||||||
|
connect: jest.fn().mockResolvedValue({
|
||||||
|
query: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
}),
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
mockRepository.mockImplementation(() => repositoryInstance);
|
mockRepository.mockImplementation(() => repositoryInstance);
|
||||||
service = new VehiclesService(repositoryInstance);
|
service = new VehiclesService(repositoryInstance, mockPool);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dropdown data integration', () => {
|
describe('dropdown data integration', () => {
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ const TIER_LEVELS: Record<SubscriptionTier, number> = {
|
|||||||
enterprise: 2,
|
enterprise: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resource limits per tier (mirrors backend VEHICLE_LIMITS)
|
||||||
|
const RESOURCE_LIMITS = {
|
||||||
|
vehicles: {
|
||||||
|
free: 2,
|
||||||
|
pro: 5,
|
||||||
|
enterprise: null, // unlimited
|
||||||
|
} as Record<SubscriptionTier, number | null>,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to check if user can access tier-gated features
|
* Hook to check if user can access tier-gated features
|
||||||
* Fetches user profile for tier and feature config from backend
|
* Fetches user profile for tier and feature config from backend
|
||||||
@@ -100,11 +109,47 @@ export const useTierAccess = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get resource limit for current user's tier
|
||||||
|
* Resource-agnostic method for count-based limits (vehicles, documents, etc.)
|
||||||
|
*
|
||||||
|
* @param resourceType - Type of resource (e.g., 'vehicles')
|
||||||
|
* @returns Maximum allowed count, or null for unlimited
|
||||||
|
*/
|
||||||
|
const getResourceLimit = (resourceType: keyof typeof RESOURCE_LIMITS): number | null => {
|
||||||
|
const limits = RESOURCE_LIMITS[resourceType];
|
||||||
|
if (!limits) {
|
||||||
|
return null; // Unknown resource type = unlimited
|
||||||
|
}
|
||||||
|
return limits[tier] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is at or over their resource limit
|
||||||
|
* Resource-agnostic method for count-based limits (vehicles, documents, etc.)
|
||||||
|
*
|
||||||
|
* @param resourceType - Type of resource (e.g., 'vehicles')
|
||||||
|
* @param currentCount - Current number of resources user has
|
||||||
|
* @returns true if user is at or over limit, false if under limit or unlimited
|
||||||
|
*/
|
||||||
|
const isAtResourceLimit = (
|
||||||
|
resourceType: keyof typeof RESOURCE_LIMITS,
|
||||||
|
currentCount: number
|
||||||
|
): boolean => {
|
||||||
|
const limit = getResourceLimit(resourceType);
|
||||||
|
if (limit === null) {
|
||||||
|
return false; // Unlimited
|
||||||
|
}
|
||||||
|
return currentCount >= limit;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tier,
|
tier,
|
||||||
loading: profileQuery.isLoading || featureConfigQuery.isLoading,
|
loading: profileQuery.isLoading || featureConfigQuery.isLoading,
|
||||||
hasAccess,
|
hasAccess,
|
||||||
checkAccess,
|
checkAccess,
|
||||||
|
getResourceLimit,
|
||||||
|
isAtResourceLimit,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 React, { useTransition, useMemo } from 'react';
|
||||||
import { Box, Typography, Grid, Button } from '@mui/material';
|
import { Box, Typography, Grid, Button } from '@mui/material';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
import { useVehicles } from '../hooks/useVehicles';
|
import { useVehicles } from '../hooks/useVehicles';
|
||||||
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
|
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
|
||||||
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
|
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
|
||||||
|
import { useVehicleLimitCheck } from '../hooks/useVehicleLimitCheck';
|
||||||
import { MobileVehiclesSuspense } from '../../../components/SuspenseWrappers';
|
import { MobileVehiclesSuspense } from '../../../components/SuspenseWrappers';
|
||||||
|
import { VehicleLimitDialog } from '../../../shared-minimal/components';
|
||||||
import { VehicleMobileCard } from './VehicleMobileCard';
|
import { VehicleMobileCard } from './VehicleMobileCard';
|
||||||
import { Vehicle } from '../types/vehicles.types';
|
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)
|
// Enhanced search with transitions (auto-syncs when vehicles change)
|
||||||
const { filteredVehicles } = useVehicleSearch(optimisticVehicles);
|
const { filteredVehicles } = useVehicleSearch(optimisticVehicles);
|
||||||
|
|
||||||
|
// Vehicle limit check
|
||||||
|
const {
|
||||||
|
isAtLimit,
|
||||||
|
limit,
|
||||||
|
tier,
|
||||||
|
showLimitDialog,
|
||||||
|
setShowLimitDialog,
|
||||||
|
} = useVehicleLimitCheck(safeVehicles.length);
|
||||||
|
|
||||||
const handleVehicleSelect = (vehicle: Vehicle) => {
|
const handleVehicleSelect = (vehicle: Vehicle) => {
|
||||||
// Use transition to avoid blocking UI during navigation
|
// Use transition to avoid blocking UI during navigation
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@@ -63,6 +75,14 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddVehicleClick = () => {
|
||||||
|
if (isAtLimit) {
|
||||||
|
setShowLimitDialog(true);
|
||||||
|
} else {
|
||||||
|
onAddVehicle?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ pb: 10 }}>
|
<Box sx={{ pb: 10 }}>
|
||||||
@@ -92,8 +112,8 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={<AddIcon />}
|
startIcon={isAtLimit ? <LockIcon /> : <AddIcon />}
|
||||||
onClick={() => onAddVehicle?.()}
|
onClick={handleAddVehicleClick}
|
||||||
sx={{ minWidth: 160 }}
|
sx={{ minWidth: 160 }}
|
||||||
>
|
>
|
||||||
Add Vehicle
|
Add Vehicle
|
||||||
@@ -119,6 +139,15 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Vehicle Limit Dialog */}
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={showLimitDialog}
|
||||||
|
onClose={() => setShowLimitDialog(false)}
|
||||||
|
currentCount={safeVehicles.length}
|
||||||
|
limit={limit ?? 0}
|
||||||
|
currentTier={tier}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</MobileVehiclesSuspense>
|
</MobileVehiclesSuspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,14 +6,17 @@
|
|||||||
import React, { useState, useTransition, useMemo, useEffect } from 'react';
|
import React, { useState, useTransition, useMemo, useEffect } from 'react';
|
||||||
import { Box, Typography, Grid, Button as MuiButton, TextField, IconButton } from '@mui/material';
|
import { Box, Typography, Grid, Button as MuiButton, TextField, IconButton } from '@mui/material';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
import { useVehicles } from '../hooks/useVehicles';
|
import { useVehicles } from '../hooks/useVehicles';
|
||||||
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
|
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
|
||||||
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
|
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
|
||||||
|
import { useVehicleLimitCheck } from '../hooks/useVehicleLimitCheck';
|
||||||
import { VehicleCard } from '../components/VehicleCard';
|
import { VehicleCard } from '../components/VehicleCard';
|
||||||
import { VehicleForm } from '../components/VehicleForm';
|
import { VehicleForm } from '../components/VehicleForm';
|
||||||
import { Card } from '../../../shared-minimal/components/Card';
|
import { Card } from '../../../shared-minimal/components/Card';
|
||||||
|
import { VehicleLimitDialog } from '../../../shared-minimal/components';
|
||||||
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
|
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
|
||||||
import { useAppStore } from '../../../core/store';
|
import { useAppStore } from '../../../core/store';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
@@ -53,6 +56,15 @@ export const VehiclesPage: React.FC = () => {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [stagedImageFile, setStagedImageFile] = useState<File | null>(null);
|
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)
|
// Auto-show form if navigated with showAddForm state (from dashboard)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = location.state as { showAddForm?: boolean } | null;
|
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 (
|
return (
|
||||||
<VehicleListSuspense>
|
<VehicleListSuspense>
|
||||||
<Box sx={{ py: 2 }}>
|
<Box sx={{ py: 2 }}>
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
mb: 4
|
mb: 4
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||||
My Vehicles
|
My Vehicles
|
||||||
</Typography>
|
</Typography>
|
||||||
{!showForm && (
|
{!showForm && (
|
||||||
<MuiButton
|
<MuiButton
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<AddIcon />}
|
startIcon={isAtLimit ? <LockIcon /> : <AddIcon />}
|
||||||
onClick={() => startTransition(() => setShowForm(true))}
|
onClick={handleAddVehicleClick}
|
||||||
sx={{ borderRadius: '999px' }}
|
sx={{ borderRadius: '999px' }}
|
||||||
disabled={isPending || isOptimisticPending}
|
disabled={isPending || isOptimisticPending}
|
||||||
>
|
>
|
||||||
@@ -208,10 +228,10 @@ export const VehiclesPage: React.FC = () => {
|
|||||||
No vehicles added yet
|
No vehicles added yet
|
||||||
</Typography>
|
</Typography>
|
||||||
{!showForm && (
|
{!showForm && (
|
||||||
<MuiButton
|
<MuiButton
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<AddIcon />}
|
startIcon={isAtLimit ? <LockIcon /> : <AddIcon />}
|
||||||
onClick={() => startTransition(() => setShowForm(true))}
|
onClick={handleAddVehicleClick}
|
||||||
sx={{ borderRadius: '999px' }}
|
sx={{ borderRadius: '999px' }}
|
||||||
disabled={isPending || isOptimisticPending}
|
disabled={isPending || isOptimisticPending}
|
||||||
>
|
>
|
||||||
@@ -234,6 +254,15 @@ export const VehiclesPage: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Vehicle Limit Dialog */}
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={showLimitDialog}
|
||||||
|
onClose={() => setShowLimitDialog(false)}
|
||||||
|
currentCount={safeVehicles.length}
|
||||||
|
limit={limit ?? 0}
|
||||||
|
currentTier={tier}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</VehicleListSuspense>
|
</VehicleListSuspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Tests for VehicleLimitDialog component
|
||||||
|
* @ai-context Validates props, mobile/desktop modes, and user interactions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { VehicleLimitDialog } from './VehicleLimitDialog';
|
||||||
|
|
||||||
|
// Mock MUI useMediaQuery to control mobile/desktop mode
|
||||||
|
jest.mock('@mui/material', () => ({
|
||||||
|
...jest.requireActual('@mui/material'),
|
||||||
|
useMediaQuery: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useMediaQuery } from '@mui/material';
|
||||||
|
const mockedUseMediaQuery = useMediaQuery as jest.MockedFunction<typeof useMediaQuery>;
|
||||||
|
|
||||||
|
describe('VehicleLimitDialog', () => {
|
||||||
|
const mockOnClose = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
// Default to desktop mode
|
||||||
|
mockedUseMediaQuery.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dialog rendering', () => {
|
||||||
|
it('renders when open', () => {
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={2}
|
||||||
|
limit={2}
|
||||||
|
currentTier="free"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Vehicle Limit Reached')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Additional Vehicles')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={false}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={2}
|
||||||
|
limit={2}
|
||||||
|
currentTier="free"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Vehicle Limit Reached')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Props display', () => {
|
||||||
|
it('displays current count and limit', () => {
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={2}
|
||||||
|
limit={2}
|
||||||
|
currentTier="free"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('You have 2 of 2 vehicles')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays free tier upgrade prompt', () => {
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={2}
|
||||||
|
limit={2}
|
||||||
|
currentTier="free"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Free tier is limited to 2 vehicles/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays pro tier upgrade prompt', () => {
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={5}
|
||||||
|
limit={5}
|
||||||
|
currentTier="pro"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Pro tier is limited to 5 vehicles/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows tier chips for free user', () => {
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={2}
|
||||||
|
limit={2}
|
||||||
|
currentTier="free"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows tier chips for pro user', () => {
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={5}
|
||||||
|
limit={5}
|
||||||
|
currentTier="pro"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User interactions', () => {
|
||||||
|
it('calls onClose when "Maybe Later" is clicked', () => {
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={2}
|
||||||
|
limit={2}
|
||||||
|
currentTier="free"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Maybe Later'));
|
||||||
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when "Upgrade (Coming Soon)" is clicked', () => {
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={2}
|
||||||
|
limit={2}
|
||||||
|
currentTier="free"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Upgrade (Coming Soon)'));
|
||||||
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Mobile responsiveness', () => {
|
||||||
|
it('renders fullscreen on mobile', () => {
|
||||||
|
mockedUseMediaQuery.mockReturnValue(true); // Mobile mode
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={2}
|
||||||
|
limit={2}
|
||||||
|
currentTier="free"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for fullScreen prop on Dialog (would be in DOM structure)
|
||||||
|
const dialog = container.querySelector('[role="dialog"]');
|
||||||
|
expect(dialog).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows close button on mobile', () => {
|
||||||
|
mockedUseMediaQuery.mockReturnValue(true); // Mobile mode
|
||||||
|
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={2}
|
||||||
|
limit={2}
|
||||||
|
currentTier="free"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeButton = screen.getByLabelText('close');
|
||||||
|
expect(closeButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides close button on desktop', () => {
|
||||||
|
mockedUseMediaQuery.mockReturnValue(false); // Desktop mode
|
||||||
|
|
||||||
|
render(
|
||||||
|
<VehicleLimitDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentCount={2}
|
||||||
|
limit={2}
|
||||||
|
currentTier="free"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText('close')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
213
frontend/src/shared-minimal/components/VehicleLimitDialog.tsx
Normal file
213
frontend/src/shared-minimal/components/VehicleLimitDialog.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Dialog shown when users reach their vehicle limit
|
||||||
|
* @ai-context Displays tier comparison and upgrade prompt for vehicle limits
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import type { SubscriptionTier } from '../../features/settings/types/profile.types';
|
||||||
|
|
||||||
|
interface VehicleLimitDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentCount: number;
|
||||||
|
limit: number;
|
||||||
|
currentTier: SubscriptionTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierDisplayNames: Record<SubscriptionTier, string> = {
|
||||||
|
free: 'Free',
|
||||||
|
pro: 'Pro',
|
||||||
|
enterprise: 'Enterprise',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tierColors: Record<SubscriptionTier, 'default' | 'primary' | 'secondary'> = {
|
||||||
|
free: 'default',
|
||||||
|
pro: 'primary',
|
||||||
|
enterprise: 'secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUpgradePrompt = (tier: SubscriptionTier): string => {
|
||||||
|
if (tier === 'free') {
|
||||||
|
return 'Free tier is limited to 2 vehicles. Upgrade to Pro for up to 5 vehicles, or Enterprise for unlimited.';
|
||||||
|
} else if (tier === 'pro') {
|
||||||
|
return 'Pro tier is limited to 5 vehicles. Upgrade to Enterprise for unlimited vehicles.';
|
||||||
|
}
|
||||||
|
return 'Upgrade to access additional vehicles.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRequiredTier = (tier: SubscriptionTier): SubscriptionTier => {
|
||||||
|
if (tier === 'free') return 'pro';
|
||||||
|
if (tier === 'pro') return 'enterprise';
|
||||||
|
return 'pro';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VehicleLimitDialog: React.FC<VehicleLimitDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
currentCount,
|
||||||
|
limit,
|
||||||
|
currentTier,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const requiredTier = getRequiredTier(currentTier);
|
||||||
|
const upgradePrompt = getUpgradePrompt(currentTier);
|
||||||
|
|
||||||
|
const handleUpgradeClick = () => {
|
||||||
|
// Navigate to upgrade page when Stripe integration is added
|
||||||
|
// For now, just close the dialog
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
fullScreen={isSmall}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
maxHeight: isSmall ? '100%' : '90vh',
|
||||||
|
m: isSmall ? 0 : 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSmall && (
|
||||||
|
<IconButton
|
||||||
|
aria-label="close"
|
||||||
|
onClick={onClose}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: 8,
|
||||||
|
color: (theme) => theme.palette.grey[500],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<LockOutlinedIcon color="action" />
|
||||||
|
Vehicle Limit Reached
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{/* Current status */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Additional Vehicles
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{upgradePrompt}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Current count indicator */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
py: 2,
|
||||||
|
px: 3,
|
||||||
|
bgcolor: 'warning.light',
|
||||||
|
borderRadius: 1,
|
||||||
|
color: 'warning.contrastText',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body1" fontWeight="medium">
|
||||||
|
You have {currentCount} of {limit} vehicles
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Tier comparison */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
py: 2,
|
||||||
|
px: 3,
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Your Plan
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={tierDisplayNames[currentTier]}
|
||||||
|
color={tierColors[currentTier]}
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h5" color="text.secondary">
|
||||||
|
→
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Upgrade to
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={tierDisplayNames[requiredTier]}
|
||||||
|
color={tierColors[requiredTier]}
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Benefits preview */}
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Upgrade to unlock {requiredTier === 'enterprise' ? 'unlimited vehicles' : 'more vehicles'} and other premium capabilities.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 3, pt: 1, flexDirection: isSmall ? 'column' : 'row', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth={isSmall}
|
||||||
|
sx={{ order: isSmall ? 2 : 1 }}
|
||||||
|
>
|
||||||
|
Maybe Later
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleUpgradeClick}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
fullWidth={isSmall}
|
||||||
|
sx={{ order: isSmall ? 1 : 2 }}
|
||||||
|
>
|
||||||
|
Upgrade (Coming Soon)
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VehicleLimitDialog;
|
||||||
@@ -5,3 +5,4 @@
|
|||||||
export { Button } from './Button';
|
export { Button } from './Button';
|
||||||
export { Card } from './Card';
|
export { Card } from './Card';
|
||||||
export { UpgradeRequiredDialog } from './UpgradeRequiredDialog';
|
export { UpgradeRequiredDialog } from './UpgradeRequiredDialog';
|
||||||
|
export { VehicleLimitDialog } from './VehicleLimitDialog';
|
||||||
|
|||||||
Reference in New Issue
Block a user