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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user