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
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:
174
frontend/src/features/vehicles/hooks/useVinOcr.ts
Normal file
174
frontend/src/features/vehicles/hooks/useVinOcr.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user