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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user