/** * @ai-summary Vehicle form component for create/edit with dropdown cascades */ import React, { useState, useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Button } from '../../../shared-minimal/components/Button'; import { CreateVehicleRequest, Vehicle, VinReviewSelections } from '../types/vehicles.types'; 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); const vehicleSchema = z .object({ vin: z.string().max(17).nullable().optional().transform(val => val ?? undefined), year: z.preprocess(nanToNull, z.number().min(1950).max(new Date().getFullYear() + 1).nullable().optional()), make: z.string().nullable().optional(), model: z.string().nullable().optional(), engine: z.string().nullable().optional(), transmission: z.string().nullable().optional(), trimLevel: z.string().nullable().optional(), driveType: z.string().nullable().optional(), fuelType: z.string().nullable().optional(), nickname: z.string().nullable().optional(), color: z.string().nullable().optional(), licensePlate: z.string().nullable().optional(), odometerReading: z.preprocess(nanToNull, z.number().min(0).nullable().optional()), purchasePrice: z.preprocess(nanToNull, z.number().min(0).nullable().optional()), purchaseDate: z.string().nullable().optional(), }) .refine( (data) => { // Pre-1981 vehicles have no VIN/plate requirement if (data.year && data.year < 1981) { return true; } const vin = (data.vin || '').trim(); const plate = (data.licensePlate || '').trim(); // 1981+: Must have either a valid 17-char VIN or a non-empty license plate if (vin.length === 17) return true; if (plate.length > 0) return true; return false; }, { message: 'Either a valid 17-character VIN or a license plate is required', path: ['vin'], } ) .refine( (data) => { // Pre-1981 vehicles accept any length VIN (1-17 chars) if (data.year && data.year < 1981) { const vin = (data.vin || '').trim(); // Empty is fine, or any length up to 17 return vin.length === 0 || (vin.length >= 1 && vin.length <= 17); } const vin = (data.vin || '').trim(); const plate = (data.licensePlate || '').trim(); // 1981+: If plate exists, allow any VIN (or empty); otherwise VIN must be exactly 17 or empty if (plate.length > 0) return true; return vin.length === 17 || vin.length === 0; }, { message: 'VIN must be exactly 17 characters when license plate is not provided', path: ['vin'], } ); interface VehicleFormProps { onSubmit: (data: CreateVehicleRequest) => void; onCancel: () => void; initialData?: Partial & { id?: string; imageUrl?: string }; loading?: boolean; onImageUpdate?: (vehicle: Vehicle) => void; onStagedImage?: (file: File | null) => void; } export const VehicleForm: React.FC = ({ onSubmit, onCancel, initialData, loading, onImageUpdate, onStagedImage, }) => { const [years, setYears] = useState([]); const [makes, setMakes] = useState([]); const [models, setModels] = useState([]); const [engines, setEngines] = useState([]); const [trims, setTrims] = useState([]); const [transmissions, setTransmissions] = useState([]); const [loadingDropdowns, setLoadingDropdowns] = useState(false); const hasInitialized = useRef(false); const isInitializing = useRef(false); const isVinDecoding = useRef(false); // Track previous values for cascade change detection (replaces useState) const prevYear = useRef(undefined); const prevMake = useRef(''); const prevModel = useRef(''); const prevTrim = useRef(''); const [currentImageUrl, setCurrentImageUrl] = useState(initialData?.imageUrl); const [previewUrl, setPreviewUrl] = useState(null); const [isDecoding, setIsDecoding] = useState(false); 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'); const isEditMode = !!initialData?.id; const vehicleId = initialData?.id; const { register, handleSubmit, formState: { errors }, watch, setValue, reset, } = useForm({ resolver: zodResolver(vehicleSchema), defaultValues: initialData, }); const watchedYear = watch('year'); const watchedMake = watch('make'); const watchedModel = watch('model'); const watchedTrim = watch('trimLevel'); const watchedEngine = watch('engine'); const watchedTransmission = watch('transmission'); // Load years on component mount useEffect(() => { const loadYears = async () => { try { const yearsData = await vehiclesApi.getYears(); setYears(yearsData); } catch (error) { console.error('Failed to load years:', error); } }; loadYears(); }, []); // Initialize dropdowns when editing existing vehicle useEffect(() => { const initializeEditMode = async () => { // Only run once and only if we have initialData if (hasInitialized.current || !initialData || !initialData.year) return; hasInitialized.current = true; isInitializing.current = true; try { setLoadingDropdowns(true); // Step 1: Set year and load makes prevYear.current = initialData.year; const makesData = await vehiclesApi.getMakes(initialData.year); setMakes(makesData); if (!initialData.make) { isInitializing.current = false; return; } // Step 2: Set make and load models prevMake.current = initialData.make; const modelsData = await vehiclesApi.getModels(initialData.year, initialData.make); setModels(modelsData); if (!initialData.model) { isInitializing.current = false; return; } // Step 3: Set model and load trims (transmissions loaded after trim selected) prevModel.current = initialData.model; const trimsData = await vehiclesApi.getTrims(initialData.year, initialData.make, initialData.model); setTrims(trimsData); if (initialData.trimLevel) { // Step 4: Set trim and load engines + transmissions prevTrim.current = initialData.trimLevel; const [enginesData, transmissionsData] = await Promise.all([ vehiclesApi.getEngines( initialData.year, initialData.make, initialData.model, initialData.trimLevel ), vehiclesApi.getTransmissions( initialData.year, initialData.make, initialData.model, initialData.trimLevel ) ]); setEngines(enginesData); setTransmissions(transmissionsData); } isInitializing.current = false; } catch (error) { console.error('Failed to initialize edit mode:', error); isInitializing.current = false; } finally { setLoadingDropdowns(false); } }; initializeEditMode(); }, [initialData]); // Run when initialData is available // Reset form values after initialization useEffect(() => { if (!isInitializing.current && initialData) { reset(initialData); } }, [initialData, reset]); // Load makes when year changes useEffect(() => { // Skip during initialization or VIN decoding if (isInitializing.current || isVinDecoding.current) return; if (watchedYear && watchedYear !== prevYear.current) { const loadMakes = async () => { setLoadingDropdowns(true); try { const makesData = await vehiclesApi.getMakes(watchedYear); setMakes(makesData); prevYear.current = watchedYear; // Clear dependent selections prevMake.current = ''; prevModel.current = ''; prevTrim.current = ''; setModels([]); setTrims([]); setEngines([]); setTransmissions([]); setValue('make', ''); setValue('model', ''); setValue('trimLevel', ''); setValue('transmission', ''); setValue('engine', ''); } catch (error) { console.error('Failed to load makes:', error); setMakes([]); } finally { setLoadingDropdowns(false); } }; loadMakes(); } }, [watchedYear, setValue]); // Load models when make changes useEffect(() => { // Skip during initialization or VIN decoding if (isInitializing.current || isVinDecoding.current) return; if (watchedMake && watchedYear && watchedMake !== prevMake.current) { const loadModels = async () => { setLoadingDropdowns(true); try { const modelsData = await vehiclesApi.getModels(watchedYear, watchedMake); setModels(modelsData); prevMake.current = watchedMake; // Clear dependent selections prevModel.current = ''; prevTrim.current = ''; setTrims([]); setEngines([]); setTransmissions([]); setValue('model', ''); setValue('trimLevel', ''); setValue('transmission', ''); setValue('engine', ''); } catch (error) { console.error('Failed to load models:', error); setModels([]); } finally { setLoadingDropdowns(false); } }; loadModels(); } }, [watchedMake, watchedYear, setValue]); // Load trims when model changes useEffect(() => { // Skip during initialization or VIN decoding if (isInitializing.current || isVinDecoding.current) return; if (watchedModel && watchedYear && watchedMake && watchedModel !== prevModel.current) { const loadTrims = async () => { setLoadingDropdowns(true); try { const trimsData = await vehiclesApi.getTrims(watchedYear, watchedMake, watchedModel); setTrims(trimsData); prevModel.current = watchedModel; // Clear deeper selections (engines, transmissions) prevTrim.current = ''; setTransmissions([]); setEngines([]); setValue('trimLevel', ''); setValue('transmission', ''); setValue('engine', ''); } catch (error) { console.error('Failed to load trims:', error); setTrims([]); } finally { setLoadingDropdowns(false); } }; loadTrims(); } }, [watchedModel, watchedYear, watchedMake, setValue]); // Load engines and transmissions when trim changes useEffect(() => { // Skip during initialization or VIN decoding if (isInitializing.current || isVinDecoding.current) return; if (watchedTrim && watchedYear && watchedMake && watchedModel && watchedTrim !== prevTrim.current) { const loadEnginesAndTransmissions = async () => { setLoadingDropdowns(true); try { const [enginesData, transmissionsData] = await Promise.all([ vehiclesApi.getEngines( watchedYear, watchedMake, watchedModel, watchedTrim ), vehiclesApi.getTransmissions( watchedYear, watchedMake, watchedModel, watchedTrim ) ]); setEngines(enginesData); setTransmissions(transmissionsData); prevTrim.current = watchedTrim; } catch (error) { console.error('Failed to load engines and transmissions:', error); setEngines([]); setTransmissions([]); } finally { setLoadingDropdowns(false); } }; loadEnginesAndTransmissions(); } }, [watchedYear, watchedMake, watchedModel, watchedTrim, setValue]); const handleImageUpload = async (file: File) => { if (isEditMode && vehicleId) { // Edit mode: upload immediately to server const updated = await vehiclesApi.uploadImage(vehicleId, file); setCurrentImageUrl(updated.imageUrl); onImageUpdate?.(updated); } else { // Create mode: stage file locally for upload after vehicle creation const objectUrl = URL.createObjectURL(file); setPreviewUrl(objectUrl); onStagedImage?.(file); } }; const handleImageRemove = async () => { if (isEditMode && vehicleId) { // Edit mode: delete from server await vehiclesApi.deleteImage(vehicleId); setCurrentImageUrl(undefined); if (initialData) { onImageUpdate?.({ ...initialData, imageUrl: undefined } as Vehicle); } } else { // Create mode: clear staged file if (previewUrl) { URL.revokeObjectURL(previewUrl); } setPreviewUrl(null); onStagedImage?.(null); } }; // Watch current form values for image preview (uses make for logo fallback) const watchedColor = watch('color'); const currentMake = watch('make') || initialData?.make; const vehicleForImage: Vehicle = { id: vehicleId || '', userId: '', vin: initialData?.vin || '', make: currentMake, model: initialData?.model, year: initialData?.year, color: watchedColor || initialData?.color, odometerReading: initialData?.odometerReading || 0, isActive: true, createdAt: '', updatedAt: '', imageUrl: previewUrl || currentImageUrl, }; // Watch VIN for decode button const watchedVin = watch('vin'); /** * Handle accepting VIN OCR result with user-edited selections from review modal * Populates VIN and selected dropdown values into the form */ const handleAcceptVinOcr = async (selections: VinReviewSelections) => { // Clear the OCR result state vinOcr.acceptResult(); // Set the VIN immediately setValue('vin', selections.vin); // Populate form with user's dropdown selections const hasSelections = selections.year || selections.make || selections.model || selections.trimLevel || selections.engine || selections.transmission; if (hasSelections) { // Prevent cascade useEffects from clearing values isVinDecoding.current = true; setLoadingDropdowns(true); try { // Load dropdown options hierarchically for the selected values if (selections.year) { prevYear.current = selections.year; const makesData = await vehiclesApi.getMakes(selections.year); setMakes(makesData); if (selections.make) { prevMake.current = selections.make; const modelsData = await vehiclesApi.getModels(selections.year, selections.make); setModels(modelsData); if (selections.model) { prevModel.current = selections.model; const trimsData = await vehiclesApi.getTrims(selections.year, selections.make, selections.model); setTrims(trimsData); if (selections.trimLevel) { prevTrim.current = selections.trimLevel; const [enginesData, transmissionsData] = await Promise.all([ vehiclesApi.getEngines(selections.year, selections.make, selections.model, selections.trimLevel), vehiclesApi.getTransmissions(selections.year, selections.make, selections.model, selections.trimLevel), ]); setEngines(enginesData); setTransmissions(transmissionsData); } } } } // Set form values after options are loaded if (selections.year) setValue('year', selections.year); if (selections.make) setValue('make', selections.make); if (selections.model) setValue('model', selections.model); if (selections.trimLevel) setValue('trimLevel', selections.trimLevel); if (selections.engine) setValue('engine', selections.engine); if (selections.transmission) setValue('transmission', selections.transmission); } finally { setLoadingDropdowns(false); isVinDecoding.current = false; } } }; /** * 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 */ const handleDecodeVin = async () => { // Check tier access first if (!hasVinDecodeAccess) { setShowUpgradeDialog(true); return; } const vin = watchedVin?.trim(); if (!vin || vin.length !== 17) { setDecodeError('Please enter a valid 17-character VIN'); return; } setIsDecoding(true); setDecodeError(null); try { const decoded = await vehiclesApi.decodeVin(vin); // Prevent cascade useEffects from clearing values during VIN decode isVinDecoding.current = true; setLoadingDropdowns(true); // Determine final values (decoded value if field is empty, otherwise keep existing) const yearValue = !watchedYear && decoded.year.value ? decoded.year.value : watchedYear; const makeValue = !watchedMake && decoded.make.value ? decoded.make.value : watchedMake; const modelValue = !watchedModel && decoded.model.value ? decoded.model.value : watchedModel; const trimValue = !watchedTrim && decoded.trimLevel.value ? decoded.trimLevel.value : watchedTrim; // FIRST: Load all dropdown options hierarchically (like edit mode initialization) // Options must exist BEFORE setting form values for selects to display correctly 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); } } } } // THEN: Set form values (after options are loaded) if (!watchedYear && decoded.year.value) { setValue('year', decoded.year.value); } if (!watchedMake && decoded.make.value) { setValue('make', decoded.make.value); } if (!watchedModel && decoded.model.value) { setValue('model', decoded.model.value); } if (!watchedTrim && decoded.trimLevel.value) { setValue('trimLevel', decoded.trimLevel.value); } if (!watchedEngine && decoded.engine.value) { setValue('engine', decoded.engine.value); } if (!watchedTransmission && decoded.transmission.value) { setValue('transmission', decoded.transmission.value); } setLoadingDropdowns(false); isVinDecoding.current = false; } catch (error: any) { console.error('VIN decode failed:', error); if (error.response?.data?.error === 'TIER_REQUIRED') { setShowUpgradeDialog(true); } else if (error.response?.data?.error === 'INVALID_VIN') { setDecodeError(error.response.data.message || 'Invalid VIN format'); } else { setDecodeError('Failed to decode VIN. Please try again.'); } } finally { setIsDecoding(false); setLoadingDropdowns(false); isVinDecoding.current = false; } }; return (

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

{errors.vin && (

{errors.vin.message}

)} {decodeError && (

{decodeError}

)} {vinOcr.error && (

{vinOcr.error}

)} {!hasVinDecodeAccess && (

VIN decode requires Pro or Enterprise subscription

)}
{/* Vehicle Specification Dropdowns */}
{/* Trim (left) */}
{/* Engine (middle) */}
{/* Transmission (right) */}
{errors.licensePlate && (

{errors.licensePlate.message}

)}
{/* Purchase Information Section */}

Purchase Information

{/* Upgrade Required Dialog for VIN Decode */} setShowUpgradeDialog(false)} /> {/* VIN OCR Review Modal */} ); };