From d6e74d89b3875b84d25fcb063730a8b2b4e34388 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:17:56 -0600 Subject: [PATCH] feat: integrate VIN capture with vehicle form (refs #68) - 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 --- .../vehicles/components/VehicleForm.tsx | 145 ++++++- .../vehicles/components/VinCameraButton.tsx | 132 +++++++ .../vehicles/components/VinOcrReviewModal.tsx | 372 ++++++++++++++++++ .../src/features/vehicles/hooks/useVinOcr.ts | 174 ++++++++ 4 files changed, 816 insertions(+), 7 deletions(-) create mode 100644 frontend/src/features/vehicles/components/VinCameraButton.tsx create mode 100644 frontend/src/features/vehicles/components/VinOcrReviewModal.tsx create mode 100644 frontend/src/features/vehicles/hooks/useVinOcr.ts diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index 2aa0717..6420210 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -12,6 +12,9 @@ import { vehiclesApi } from '../api/vehicles.api'; import { VehicleImageUpload } from './VehicleImageUpload'; import { useTierAccess } from '../../../core/hooks/useTierAccess'; import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog'; +import { VinCameraButton } from './VinCameraButton'; +import { VinOcrReviewModal } from './VinOcrReviewModal'; +import { useVinOcr } from '../hooks/useVinOcr'; // Helper to convert NaN (from empty number inputs) to null const nanToNull = (val: unknown) => (typeof val === 'number' && isNaN(val) ? null : val); @@ -112,6 +115,9 @@ export const VehicleForm: React.FC = ({ const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); const [decodeError, setDecodeError] = useState(null); + // VIN OCR capture hook + const vinOcr = useVinOcr(); + // Tier access check for VIN decode feature const { hasAccess: canDecodeVin } = useTierAccess(); const hasVinDecodeAccess = canDecodeVin('vehicle.vinDecode'); @@ -426,6 +432,107 @@ export const VehicleForm: React.FC = ({ // Watch VIN for decode button const watchedVin = watch('vin'); + /** + * Handle accepting VIN OCR result + * Populates VIN and decoded fields into the form + */ + const handleAcceptVinOcr = async () => { + const result = vinOcr.acceptResult(); + if (!result) return; + + const { ocrResult, decodedVehicle } = result; + + // Set the VIN immediately + setValue('vin', ocrResult.vin); + + // If we have decoded vehicle data, populate the form similar to handleDecodeVin + if (decodedVehicle) { + // Prevent cascade useEffects from clearing values + isVinDecoding.current = true; + setLoadingDropdowns(true); + + try { + // Determine final values + const yearValue = decodedVehicle.year.value; + const makeValue = decodedVehicle.make.value; + const modelValue = decodedVehicle.model.value; + const trimValue = decodedVehicle.trimLevel.value; + + // Load dropdown options hierarchically + if (yearValue) { + prevYear.current = yearValue; + const makesData = await vehiclesApi.getMakes(yearValue); + setMakes(makesData); + + if (makeValue) { + prevMake.current = makeValue; + const modelsData = await vehiclesApi.getModels(yearValue, makeValue); + setModels(modelsData); + + if (modelValue) { + prevModel.current = modelValue; + const trimsData = await vehiclesApi.getTrims(yearValue, makeValue, modelValue); + setTrims(trimsData); + + if (trimValue) { + prevTrim.current = trimValue; + const [enginesData, transmissionsData] = await Promise.all([ + vehiclesApi.getEngines(yearValue, makeValue, modelValue, trimValue), + vehiclesApi.getTransmissions(yearValue, makeValue, modelValue, trimValue), + ]); + setEngines(enginesData); + setTransmissions(transmissionsData); + } + } + } + } + + // Set form values after options are loaded + if (decodedVehicle.year.value) { + setValue('year', decodedVehicle.year.value); + } + if (decodedVehicle.make.value) { + setValue('make', decodedVehicle.make.value); + } + if (decodedVehicle.model.value) { + setValue('model', decodedVehicle.model.value); + } + if (decodedVehicle.trimLevel.value) { + setValue('trimLevel', decodedVehicle.trimLevel.value); + } + if (decodedVehicle.engine.value) { + setValue('engine', decodedVehicle.engine.value); + } + if (decodedVehicle.transmission.value) { + setValue('transmission', decodedVehicle.transmission.value); + } + } finally { + setLoadingDropdowns(false); + isVinDecoding.current = false; + } + } + }; + + /** + * Handle editing manually after VIN OCR + * Just sets the VIN and closes the modal + */ + const handleEditVinManually = () => { + const result = vinOcr.acceptResult(); + if (result) { + setValue('vin', result.ocrResult.vin); + } + }; + + /** + * Handle retaking VIN photo + * Resets and restarts capture + */ + const handleRetakeVinPhoto = () => { + vinOcr.reset(); + vinOcr.startCapture(); + }; + /** * Handle VIN decode button click * Calls NHTSA API and populates empty form fields @@ -546,15 +653,26 @@ export const VehicleForm: React.FC = ({ VIN Number *

- Enter vehicle VIN (optional if License Plate provided) + Enter vehicle VIN or scan with camera (optional if License Plate provided)

- +
+ + +
+ + + + + ); +}; + +export const VinOcrReviewModal: React.FC = ({ + open, + result, + onAccept, + onEdit, + onRetake, + onClose, +}) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + if (!result) return null; + + // Use bottom sheet on mobile, dialog on desktop + if (isMobile) { + return ( + + + {/* Drag handle */} + + + VIN Detected + + + + + ); + } + + // Desktop dialog + return ( + + VIN Detected + + + + + {/* Actions are in ReviewContent */} + + + ); +}; diff --git a/frontend/src/features/vehicles/hooks/useVinOcr.ts b/frontend/src/features/vehicles/hooks/useVinOcr.ts new file mode 100644 index 0000000..07d4dfe --- /dev/null +++ b/frontend/src/features/vehicles/hooks/useVinOcr.ts @@ -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; + acceptResult: () => VinCaptureResult | null; + reset: () => void; +} + +/** + * Extract VIN from image using OCR service + */ +async function extractVinFromImage(file: File): Promise { + 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(null); + const [error, setError] = useState(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, + }; +}