feat: integrate VIN capture with vehicle form (refs #68)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m12s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 32s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

- Add VinCameraButton component that opens CameraCapture with VIN guidance
- Add VinOcrReviewModal showing extracted VIN and decoded vehicle data
  - Confidence indicators (high/medium/low) for each field
  - Mobile-responsive bottom sheet on small screens
  - Accept, Edit Manually, or Retake Photo options
- Add useVinOcr hook orchestrating OCR extraction and NHTSA decode
- Update VehicleForm with camera button next to VIN input
- Form auto-populates with OCR result and decoded data on accept

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-01 20:17:56 -06:00
parent e1d12d049a
commit d6e74d89b3
4 changed files with 816 additions and 7 deletions

View File

@@ -0,0 +1,174 @@
/**
* @ai-summary Hook to orchestrate VIN OCR extraction and NHTSA decode
* @ai-context Handles camera capture -> OCR extraction -> VIN decode flow
*/
import { useState, useCallback } from 'react';
import { apiClient } from '../../../core/api/client';
import { vehiclesApi } from '../api/vehicles.api';
import { DecodedVehicleData } from '../types/vehicles.types';
/** OCR extraction result for VIN */
export interface VinOcrResult {
vin: string;
confidence: number;
rawText: string;
}
/** Combined OCR + decode result */
export interface VinCaptureResult {
ocrResult: VinOcrResult;
decodedVehicle: DecodedVehicleData | null;
decodeError: string | null;
}
/** Hook state */
export interface UseVinOcrState {
isCapturing: boolean;
isProcessing: boolean;
processingStep: 'idle' | 'extracting' | 'decoding';
result: VinCaptureResult | null;
error: string | null;
}
/** Hook return type */
export interface UseVinOcrReturn extends UseVinOcrState {
startCapture: () => void;
cancelCapture: () => void;
processImage: (file: File, croppedFile?: File) => Promise<void>;
acceptResult: () => VinCaptureResult | null;
reset: () => void;
}
/**
* Extract VIN from image using OCR service
*/
async function extractVinFromImage(file: File): Promise<VinOcrResult> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post('/ocr/extract', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000, // 30 seconds for OCR processing
});
const data = response.data;
if (!data.success) {
throw new Error('OCR extraction failed');
}
// Extract VIN from the response
const vinField = data.extractedFields?.vin;
if (!vinField?.value) {
throw new Error('No VIN found in image. Please ensure the VIN is clearly visible.');
}
return {
vin: vinField.value.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, ''),
confidence: vinField.confidence,
rawText: data.rawText,
};
}
/**
* Hook to orchestrate VIN photo capture, OCR, and decode
*/
export function useVinOcr(): UseVinOcrReturn {
const [isCapturing, setIsCapturing] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [processingStep, setProcessingStep] = useState<'idle' | 'extracting' | 'decoding'>('idle');
const [result, setResult] = useState<VinCaptureResult | null>(null);
const [error, setError] = useState<string | null>(null);
const startCapture = useCallback(() => {
setIsCapturing(true);
setError(null);
setResult(null);
}, []);
const cancelCapture = useCallback(() => {
setIsCapturing(false);
setError(null);
}, []);
const processImage = useCallback(async (file: File, croppedFile?: File) => {
setIsCapturing(false);
setIsProcessing(true);
setError(null);
setResult(null);
try {
// Step 1: Extract VIN from image
setProcessingStep('extracting');
const imageToProcess = croppedFile || file;
const ocrResult = await extractVinFromImage(imageToProcess);
// Validate VIN format
if (ocrResult.vin.length !== 17) {
throw new Error(
`Extracted VIN "${ocrResult.vin}" is ${ocrResult.vin.length} characters. VIN must be exactly 17 characters.`
);
}
// Step 2: Decode VIN using NHTSA
setProcessingStep('decoding');
let decodedVehicle: DecodedVehicleData | null = null;
let decodeError: string | null = null;
try {
decodedVehicle = await vehiclesApi.decodeVin(ocrResult.vin);
} catch (err: any) {
// VIN decode failure is not fatal - we still have the VIN
if (err.response?.data?.error === 'TIER_REQUIRED') {
decodeError = 'VIN decode requires Pro or Enterprise subscription';
} else if (err.response?.data?.error === 'INVALID_VIN') {
decodeError = 'VIN format is not recognized by NHTSA';
} else {
decodeError = 'Unable to decode vehicle information';
}
console.warn('VIN decode failed:', err);
}
setResult({
ocrResult,
decodedVehicle,
decodeError,
});
} catch (err: any) {
console.error('VIN OCR processing failed:', err);
const message = err.response?.data?.message || err.message || 'Failed to process image';
setError(message);
} finally {
setIsProcessing(false);
setProcessingStep('idle');
}
}, []);
const acceptResult = useCallback(() => {
const currentResult = result;
setResult(null);
return currentResult;
}, [result]);
const reset = useCallback(() => {
setIsCapturing(false);
setIsProcessing(false);
setProcessingStep('idle');
setResult(null);
setError(null);
}, []);
return {
isCapturing,
isProcessing,
processingStep,
result,
error,
startCapture,
cancelCapture,
processImage,
acceptResult,
reset,
};
}