Merge pull request 'feat: VIN Capture Integration (#68)' (#76) from issue-68-vin-capture-integration into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 30s
Deploy to Staging / Deploy to Staging (push) Successful in 31s
Deploy to Staging / Verify Staging (push) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped

Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
2026-02-02 02:27:28 +00:00
4 changed files with 816 additions and 7 deletions

View File

@@ -12,6 +12,9 @@ import { vehiclesApi } from '../api/vehicles.api';
import { VehicleImageUpload } from './VehicleImageUpload'; import { VehicleImageUpload } from './VehicleImageUpload';
import { useTierAccess } from '../../../core/hooks/useTierAccess'; import { useTierAccess } from '../../../core/hooks/useTierAccess';
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog'; 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 // Helper to convert NaN (from empty number inputs) to null
const nanToNull = (val: unknown) => (typeof val === 'number' && isNaN(val) ? null : val); const nanToNull = (val: unknown) => (typeof val === 'number' && isNaN(val) ? null : val);
@@ -112,6 +115,9 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
const [decodeError, setDecodeError] = useState<string | null>(null); const [decodeError, setDecodeError] = useState<string | null>(null);
// VIN OCR capture hook
const vinOcr = useVinOcr();
// Tier access check for VIN decode feature // Tier access check for VIN decode feature
const { hasAccess: canDecodeVin } = useTierAccess(); const { hasAccess: canDecodeVin } = useTierAccess();
const hasVinDecodeAccess = canDecodeVin('vehicle.vinDecode'); const hasVinDecodeAccess = canDecodeVin('vehicle.vinDecode');
@@ -426,6 +432,107 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
// Watch VIN for decode button // Watch VIN for decode button
const watchedVin = watch('vin'); 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 * Handle VIN decode button click
* Calls NHTSA API and populates empty form fields * Calls NHTSA API and populates empty form fields
@@ -546,15 +653,26 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
VIN Number <span className="text-red-500">*</span> VIN Number <span className="text-red-500">*</span>
</label> </label>
<p className="text-xs text-gray-600 dark:text-titanio mb-2"> <p className="text-xs text-gray-600 dark:text-titanio mb-2">
Enter vehicle VIN (optional if License Plate provided) Enter vehicle VIN or scan with camera (optional if License Plate provided)
</p> </p>
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1 flex gap-2">
<input <input
{...register('vin')} {...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" 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" placeholder="Enter 17-character VIN"
style={{ fontSize: '16px' }} style={{ fontSize: '16px' }}
/> />
<VinCameraButton
disabled={loading || loadingDropdowns}
isCapturing={vinOcr.isCapturing}
isProcessing={vinOcr.isProcessing}
processingStep={vinOcr.processingStep}
onStartCapture={vinOcr.startCapture}
onCancelCapture={vinOcr.cancelCapture}
onImageCapture={vinOcr.processImage}
/>
</div>
<button <button
type="button" type="button"
onClick={handleDecodeVin} onClick={handleDecodeVin}
@@ -581,6 +699,9 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
{decodeError && ( {decodeError && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{decodeError}</p> <p className="mt-1 text-sm text-red-600 dark:text-red-400">{decodeError}</p>
)} )}
{vinOcr.error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{vinOcr.error}</p>
)}
{!hasVinDecodeAccess && ( {!hasVinDecodeAccess && (
<p className="mt-1 text-xs text-gray-500 dark:text-titanio"> <p className="mt-1 text-xs text-gray-500 dark:text-titanio">
VIN decode requires Pro or Enterprise subscription VIN decode requires Pro or Enterprise subscription
@@ -880,6 +1001,16 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
open={showUpgradeDialog} open={showUpgradeDialog}
onClose={() => setShowUpgradeDialog(false)} onClose={() => setShowUpgradeDialog(false)}
/> />
{/* VIN OCR Review Modal */}
<VinOcrReviewModal
open={!!vinOcr.result}
result={vinOcr.result}
onAccept={handleAcceptVinOcr}
onEdit={handleEditVinManually}
onRetake={handleRetakeVinPhoto}
onClose={vinOcr.reset}
/>
</form> </form>
); );
}; };

View File

@@ -0,0 +1,132 @@
/**
* @ai-summary Camera button for VIN capture that opens CameraCapture modal
* @ai-context Renders camera icon button and full-screen camera overlay
*/
import React from 'react';
import { IconButton, Tooltip, Modal, Box, CircularProgress, Typography } from '@mui/material';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import { CameraCapture } from '../../../shared/components/CameraCapture/CameraCapture';
interface VinCameraButtonProps {
disabled?: boolean;
isCapturing: boolean;
isProcessing: boolean;
processingStep: 'idle' | 'extracting' | 'decoding';
onStartCapture: () => void;
onCancelCapture: () => void;
onImageCapture: (file: File, croppedFile?: File) => void;
}
export const VinCameraButton: React.FC<VinCameraButtonProps> = ({
disabled,
isCapturing,
isProcessing,
processingStep,
onStartCapture,
onCancelCapture,
onImageCapture,
}) => {
const getProcessingMessage = () => {
switch (processingStep) {
case 'extracting':
return 'Extracting VIN...';
case 'decoding':
return 'Decoding vehicle info...';
default:
return 'Processing...';
}
};
return (
<>
<Tooltip title="Scan VIN with camera">
<span>
<IconButton
onClick={onStartCapture}
disabled={disabled || isProcessing}
size="large"
sx={{
backgroundColor: 'primary.main',
color: 'white',
'&:hover': {
backgroundColor: 'primary.dark',
},
'&.Mui-disabled': {
backgroundColor: 'action.disabledBackground',
color: 'action.disabled',
},
minWidth: 44,
minHeight: 44,
}}
aria-label="Scan VIN with camera"
>
{isProcessing ? (
<CircularProgress size={24} color="inherit" />
) : (
<CameraAltIcon />
)}
</IconButton>
</span>
</Tooltip>
{/* Full-screen camera modal */}
<Modal
open={isCapturing}
onClose={onCancelCapture}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
width: '100%',
height: '100%',
backgroundColor: 'black',
outline: 'none',
}}
>
<CameraCapture
onCapture={onImageCapture}
onCancel={onCancelCapture}
guidanceType="vin"
allowCrop={true}
/>
</Box>
</Modal>
{/* Processing overlay */}
<Modal
open={isProcessing}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
p: 4,
backgroundColor: 'background.paper',
borderRadius: 2,
boxShadow: 24,
outline: 'none',
minWidth: 200,
}}
>
<CircularProgress size={48} />
<Typography variant="body1" color="text.secondary">
{getProcessingMessage()}
</Typography>
</Box>
</Modal>
</>
);
};

View File

@@ -0,0 +1,372 @@
/**
* @ai-summary Modal to review VIN OCR results and decoded vehicle data
* @ai-context Shows extracted VIN with confidence, decoded fields, accept/edit/retake options
*/
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Alert,
useTheme,
useMediaQuery,
Drawer,
Divider,
} from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningIcon from '@mui/icons-material/Warning';
import ErrorIcon from '@mui/icons-material/Error';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import EditIcon from '@mui/icons-material/Edit';
import { VinCaptureResult } from '../hooks/useVinOcr';
import { MatchConfidence } from '../types/vehicles.types';
interface VinOcrReviewModalProps {
open: boolean;
result: VinCaptureResult | null;
onAccept: () => void;
onEdit: () => void;
onRetake: () => void;
onClose: () => void;
}
/** Get confidence level from percentage */
function getConfidenceLevel(confidence: number): 'high' | 'medium' | 'low' {
if (confidence >= 0.9) return 'high';
if (confidence >= 0.7) return 'medium';
return 'low';
}
/** Confidence indicator component */
const ConfidenceIndicator: React.FC<{ level: 'high' | 'medium' | 'low' | 'none' }> = ({
level,
}) => {
const configs = {
high: { color: 'success.main', icon: CheckCircleIcon, label: 'High' },
medium: { color: 'warning.main', icon: WarningIcon, label: 'Medium' },
low: { color: 'error.light', icon: ErrorIcon, label: 'Low' },
none: { color: 'text.disabled', icon: ErrorIcon, label: 'N/A' },
};
const config = configs[level];
const Icon = config.icon;
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
<Icon sx={{ fontSize: 16, color: config.color }} />
<Typography variant="caption" sx={{ color: config.color }}>
{config.label}
</Typography>
</Box>
);
};
/** Map match confidence to display level */
function matchConfidenceToLevel(confidence: MatchConfidence): 'high' | 'medium' | 'low' | 'none' {
switch (confidence) {
case 'high':
return 'high';
case 'medium':
return 'medium';
case 'none':
return 'none';
default:
return 'none';
}
}
/** Decoded field row component */
const DecodedFieldRow: React.FC<{
label: string;
value: string | number | null;
nhtsaValue: string | null;
confidence: MatchConfidence;
}> = ({ label, value, nhtsaValue, confidence }) => {
const displayValue = value || nhtsaValue || '-';
const level = matchConfidenceToLevel(confidence);
return (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 1,
px: 2,
backgroundColor: level === 'low' || level === 'none' ? 'action.hover' : 'transparent',
borderRadius: 1,
}}
>
<Box>
<Typography variant="body2" color="text.secondary">
{label}
</Typography>
<Typography variant="body1" fontWeight={value ? 500 : 400}>
{displayValue}
</Typography>
{nhtsaValue && value !== nhtsaValue && (
<Typography variant="caption" color="text.secondary">
NHTSA: {nhtsaValue}
</Typography>
)}
</Box>
<ConfidenceIndicator level={level} />
</Box>
);
};
/** Main modal content */
const ReviewContent: React.FC<{
result: VinCaptureResult;
onAccept: () => void;
onEdit: () => void;
onRetake: () => void;
}> = ({ result, onAccept, onEdit, onRetake }) => {
const { ocrResult, decodedVehicle, decodeError } = result;
const vinConfidenceLevel = getConfidenceLevel(ocrResult.confidence);
return (
<>
{/* VIN Section */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Detected VIN
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2,
backgroundColor: 'action.hover',
borderRadius: 1,
border: 1,
borderColor: vinConfidenceLevel === 'high' ? 'success.main' : 'warning.main',
}}
>
<Typography
variant="h6"
fontFamily="monospace"
letterSpacing={1}
sx={{ wordBreak: 'break-all' }}
>
{ocrResult.vin}
</Typography>
<ConfidenceIndicator level={vinConfidenceLevel} />
</Box>
{vinConfidenceLevel !== 'high' && (
<Typography variant="caption" color="warning.main" sx={{ mt: 1, display: 'block' }}>
Low confidence detection - please verify the VIN is correct
</Typography>
)}
</Box>
{/* Decode Error */}
{decodeError && (
<Alert severity="warning" sx={{ mb: 2 }}>
{decodeError}
</Alert>
)}
{/* Decoded Vehicle Information */}
{decodedVehicle && (
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Decoded Vehicle Information
</Typography>
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
}}
>
<DecodedFieldRow
label="Year"
value={decodedVehicle.year.value}
nhtsaValue={decodedVehicle.year.nhtsaValue}
confidence={decodedVehicle.year.confidence}
/>
<Divider />
<DecodedFieldRow
label="Make"
value={decodedVehicle.make.value}
nhtsaValue={decodedVehicle.make.nhtsaValue}
confidence={decodedVehicle.make.confidence}
/>
<Divider />
<DecodedFieldRow
label="Model"
value={decodedVehicle.model.value}
nhtsaValue={decodedVehicle.model.nhtsaValue}
confidence={decodedVehicle.model.confidence}
/>
<Divider />
<DecodedFieldRow
label="Trim"
value={decodedVehicle.trimLevel.value}
nhtsaValue={decodedVehicle.trimLevel.nhtsaValue}
confidence={decodedVehicle.trimLevel.confidence}
/>
<Divider />
<DecodedFieldRow
label="Engine"
value={decodedVehicle.engine.value}
nhtsaValue={decodedVehicle.engine.nhtsaValue}
confidence={decodedVehicle.engine.confidence}
/>
<Divider />
<DecodedFieldRow
label="Transmission"
value={decodedVehicle.transmission.value}
nhtsaValue={decodedVehicle.transmission.nhtsaValue}
confidence={decodedVehicle.transmission.confidence}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
Fields with lower confidence may need manual verification.
</Typography>
</Box>
)}
{/* No decoded data - VIN only mode */}
{!decodedVehicle && !decodeError && (
<Alert severity="info">
VIN extracted successfully. Vehicle details will need to be entered manually.
</Alert>
)}
{/* Action Buttons */}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
mt: 3,
}}
>
<Button
variant="outlined"
startIcon={<CameraAltIcon />}
onClick={onRetake}
fullWidth
sx={{ minHeight: 44 }}
>
Retake Photo
</Button>
<Button
variant="outlined"
startIcon={<EditIcon />}
onClick={onEdit}
fullWidth
sx={{ minHeight: 44 }}
>
Edit Manually
</Button>
<Button
variant="contained"
startIcon={<CheckCircleIcon />}
onClick={onAccept}
fullWidth
sx={{ minHeight: 44 }}
>
Accept
</Button>
</Box>
</>
);
};
export const VinOcrReviewModal: React.FC<VinOcrReviewModalProps> = ({
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 (
<Drawer
anchor="bottom"
open={open}
onClose={onClose}
PaperProps={{
sx: {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '90vh',
},
}}
>
<Box sx={{ p: 2 }}>
{/* Drag handle */}
<Box
sx={{
width: 32,
height: 4,
backgroundColor: 'divider',
borderRadius: 2,
mx: 'auto',
mb: 2,
}}
/>
<Typography variant="h6" gutterBottom>
VIN Detected
</Typography>
<ReviewContent
result={result}
onAccept={onAccept}
onEdit={onEdit}
onRetake={onRetake}
/>
</Box>
</Drawer>
);
}
// Desktop dialog
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: { borderRadius: 2 },
}}
>
<DialogTitle>VIN Detected</DialogTitle>
<DialogContent>
<ReviewContent
result={result}
onAccept={onAccept}
onEdit={onEdit}
onRetake={onRetake}
/>
</DialogContent>
<DialogActions sx={{ display: 'none' }}>
{/* Actions are in ReviewContent */}
</DialogActions>
</Dialog>
);
};

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,
};
}