feat: Add VIN decoding with NHTSA vPIC API (refs #9)

- Add NHTSA client for VIN decoding with caching and validation
- Add POST /api/vehicles/decode-vin endpoint with tier gating
- Add dropdown matching service with confidence levels
- Add decode button to VehicleForm with tier check
- Responsive layout: stacks on mobile, inline on desktop
- Only populate empty fields (preserve user input)

Backend:
- NHTSAClient with 5s timeout, VIN validation, vin_cache table
- Tier gating with 'vehicle.vinDecode' feature key (Pro+)
- Tiered matching: high (exact), medium (normalized), none

Frontend:
- Decode button with loading state and error handling
- UpgradeRequiredDialog for free tier users
- Mobile-first responsive layout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-11 13:55:26 -06:00
parent 84baa755d9
commit 2aae89acbe
11 changed files with 760 additions and 9 deletions

View File

@@ -3,7 +3,7 @@
*/
import { apiClient } from '../../../core/api/client';
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest } from '../types/vehicles.types';
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DecodedVehicleData } from '../types/vehicles.types';
// All requests (including dropdowns) use authenticated apiClient
@@ -79,5 +79,14 @@ export const vehiclesApi = {
getImageUrl: (vehicleId: string): string => {
return `/api/vehicles/${vehicleId}/image`;
},
/**
* Decode VIN using NHTSA vPIC API
* Requires Pro or Enterprise tier
*/
decodeVin: async (vin: string): Promise<DecodedVehicleData> => {
const response = await apiClient.post('/vehicles/decode-vin', { vin });
return response.data;
}
};

View File

@@ -10,6 +10,8 @@ import { Button } from '../../../shared-minimal/components/Button';
import { CreateVehicleRequest, Vehicle } from '../types/vehicles.types';
import { vehiclesApi } from '../api/vehicles.api';
import { VehicleImageUpload } from './VehicleImageUpload';
import { useTierAccess } from '../../../core/hooks/useTierAccess';
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
const vehicleSchema = z
.object({
@@ -100,6 +102,13 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
const prevTrim = useRef<string>('');
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isDecoding, setIsDecoding] = useState(false);
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
const [decodeError, setDecodeError] = useState<string | null>(null);
// Tier access check for VIN decode feature
const { hasAccess: canDecodeVin } = useTierAccess();
const hasVinDecodeAccess = canDecodeVin('vehicle.vinDecode');
const isEditMode = !!initialData?.id;
const vehicleId = initialData?.id;
@@ -408,6 +417,76 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
imageUrl: previewUrl || currentImageUrl,
};
// Watch VIN for decode button
const watchedVin = watch('vin');
/**
* Handle VIN decode button click
* Calls NHTSA API and populates empty form fields
*/
const handleDecodeVin = async () => {
// Check tier access first
if (!hasVinDecodeAccess) {
setShowUpgradeDialog(true);
return;
}
const vin = watchedVin?.trim();
if (!vin || vin.length !== 17) {
setDecodeError('Please enter a valid 17-character VIN');
return;
}
setIsDecoding(true);
setDecodeError(null);
try {
const decoded = await vehiclesApi.decodeVin(vin);
// Only populate empty fields (preserve existing user input)
if (!watchedYear && decoded.year.value) {
setValue('year', decoded.year.value);
}
if (!watchedMake && decoded.make.value) {
setValue('make', decoded.make.value);
}
if (!watchedModel && decoded.model.value) {
setValue('model', decoded.model.value);
}
if (!watchedTrim && decoded.trimLevel.value) {
setValue('trimLevel', decoded.trimLevel.value);
}
if (!watchedEngine && decoded.engine.value) {
setValue('engine', decoded.engine.value);
}
if (!watchedTransmission && decoded.transmission.value) {
setValue('transmission', decoded.transmission.value);
}
// Body type, drive type, fuel type - check if fields are empty and we have values
const currentDriveType = watch('driveType');
const currentFuelType = watch('fuelType');
if (!currentDriveType && decoded.driveType.nhtsaValue) {
// For now just show hint - user can select from dropdown
}
if (!currentFuelType && decoded.fuelType.nhtsaValue) {
// For now just show hint - user can select from dropdown
}
} catch (error: any) {
console.error('VIN decode failed:', error);
if (error.response?.data?.error === 'TIER_REQUIRED') {
setShowUpgradeDialog(true);
} else if (error.response?.data?.error === 'INVALID_VIN') {
setDecodeError(error.response.data.message || 'Invalid VIN format');
} else {
setDecodeError('Failed to decode VIN. Please try again.');
}
} finally {
setIsDecoding(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="mb-6">
@@ -427,18 +506,47 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
VIN Number <span className="text-red-500">*</span>
</label>
<p className="text-xs text-gray-600 dark:text-titanio mb-2">
Enter vehicle VIN (optional)
Enter vehicle VIN (optional if License Plate provided)
</p>
<input
{...register('vin')}
className="w-full px-3 py-2 border rounded-md text-base bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
placeholder="Enter 17-character VIN (optional if License Plate provided)"
style={{ fontSize: '16px' }}
/>
<div className="flex flex-col sm:flex-row gap-2">
<input
{...register('vin')}
className="flex-1 px-3 py-2 border rounded-md text-base bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
placeholder="Enter 17-character VIN"
style={{ fontSize: '16px' }}
/>
<button
type="button"
onClick={handleDecodeVin}
disabled={isDecoding || loading || loadingDropdowns || !watchedVin?.trim()}
className="px-4 py-2 min-h-[44px] min-w-full sm:min-w-[120px] bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2 dark:bg-abudhabi dark:hover:bg-primary-600"
style={{ fontSize: '16px' }}
>
{isDecoding ? (
<>
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Decoding...
</>
) : (
'Decode VIN'
)}
</button>
</div>
{errors.vin && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.vin.message}</p>
)}
</div>
{decodeError && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{decodeError}</p>
)}
{!hasVinDecodeAccess && (
<p className="mt-1 text-xs text-gray-500 dark:text-titanio">
VIN decode requires Pro or Enterprise subscription
</p>
)}
</div>
{/* Vehicle Specification Dropdowns */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
@@ -689,6 +797,13 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
</Button>
</div>
{/* Upgrade Required Dialog for VIN Decode */}
<UpgradeRequiredDialog
featureKey="vehicle.vinDecode"
open={showUpgradeDialog}
onClose={() => setShowUpgradeDialog(false)}
/>
</form>
);
};

View File

@@ -55,3 +55,33 @@ export interface UpdateVehicleRequest {
licensePlate?: string;
odometerReading?: number;
}
/**
* Confidence level for matched dropdown values from VIN decode
*/
export type MatchConfidence = 'high' | 'medium' | 'none';
/**
* Matched field with confidence indicator
*/
export interface MatchedField<T> {
value: T | null;
nhtsaValue: string | null;
confidence: MatchConfidence;
}
/**
* Decoded vehicle data from NHTSA vPIC API
* with match confidence per field
*/
export interface DecodedVehicleData {
year: MatchedField<number>;
make: MatchedField<string>;
model: MatchedField<string>;
trimLevel: MatchedField<string>;
bodyType: MatchedField<string>;
driveType: MatchedField<string>;
fuelType: MatchedField<string>;
engine: MatchedField<string>;
transmission: MatchedField<string>;
}