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

@@ -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<VehicleFormProps> = ({
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
const [decodeError, setDecodeError] = useState<string | null>(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<VehicleFormProps> = ({
// 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<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 if License Plate provided)
Enter vehicle VIN or scan with camera (optional if License Plate provided)
</p>
<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' }}
/>
<div className="flex-1 flex 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' }}
/>
<VinCameraButton
disabled={loading || loadingDropdowns}
isCapturing={vinOcr.isCapturing}
isProcessing={vinOcr.isProcessing}
processingStep={vinOcr.processingStep}
onStartCapture={vinOcr.startCapture}
onCancelCapture={vinOcr.cancelCapture}
onImageCapture={vinOcr.processImage}
/>
</div>
<button
type="button"
onClick={handleDecodeVin}
@@ -581,6 +699,9 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
{decodeError && (
<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 && (
<p className="mt-1 text-xs text-gray-500 dark:text-titanio">
VIN decode requires Pro or Enterprise subscription
@@ -880,6 +1001,16 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
open={showUpgradeDialog}
onClose={() => setShowUpgradeDialog(false)}
/>
{/* VIN OCR Review Modal */}
<VinOcrReviewModal
open={!!vinOcr.result}
result={vinOcr.result}
onAccept={handleAcceptVinOcr}
onEdit={handleEditVinManually}
onRetake={handleRetakeVinPhoto}
onClose={vinOcr.reset}
/>
</form>
);
};