All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
VIN OCR confidence now reflects recognition accuracy only (not match quality). Review modal replaces read-only fields with editable cascade dropdowns pre-populated from NHTSA decode, with NHTSA reference hints for unmatched fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
988 lines
36 KiB
TypeScript
988 lines
36 KiB
TypeScript
/**
|
|
* @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<CreateVehicleRequest> & { id?: string; imageUrl?: string };
|
|
loading?: boolean;
|
|
onImageUpdate?: (vehicle: Vehicle) => void;
|
|
onStagedImage?: (file: File | null) => void;
|
|
}
|
|
|
|
export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|
onSubmit,
|
|
onCancel,
|
|
initialData,
|
|
loading,
|
|
onImageUpdate,
|
|
onStagedImage,
|
|
}) => {
|
|
const [years, setYears] = useState<number[]>([]);
|
|
const [makes, setMakes] = useState<string[]>([]);
|
|
const [models, setModels] = useState<string[]>([]);
|
|
const [engines, setEngines] = useState<string[]>([]);
|
|
const [trims, setTrims] = useState<string[]>([]);
|
|
const [transmissions, setTransmissions] = useState<string[]>([]);
|
|
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<number | undefined>(undefined);
|
|
const prevMake = useRef<string>('');
|
|
const prevModel = useRef<string>('');
|
|
const prevTrim = useRef<string>('');
|
|
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
const [isDecoding, setIsDecoding] = useState(false);
|
|
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');
|
|
|
|
const isEditMode = !!initialData?.id;
|
|
const vehicleId = initialData?.id;
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
formState: { errors },
|
|
watch,
|
|
setValue,
|
|
reset,
|
|
} = useForm<CreateVehicleRequest>({
|
|
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 (
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-2">
|
|
Vehicle Photo
|
|
</label>
|
|
<VehicleImageUpload
|
|
vehicle={vehicleForImage}
|
|
onUpload={handleImageUpload}
|
|
onRemove={handleImageRemove}
|
|
disabled={loading || loadingDropdowns}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
VIN Number <span className="text-red-500">*</span>
|
|
</label>
|
|
<p className="text-xs text-gray-600 dark:text-titanio mb-2">
|
|
Enter vehicle VIN or scan with camera (optional if License Plate provided)
|
|
</p>
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<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}
|
|
disabled={isDecoding || loading || loadingDropdowns || !watchedVin?.trim()}
|
|
className="px-4 py-2 min-h-[44px] min-w-full sm:min-w-[120px] bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2 dark:bg-abudhabi dark:hover:bg-primary-600"
|
|
style={{ fontSize: '16px' }}
|
|
>
|
|
{isDecoding ? (
|
|
<>
|
|
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
Decoding...
|
|
</>
|
|
) : (
|
|
'Decode VIN'
|
|
)}
|
|
</button>
|
|
</div>
|
|
{errors.vin && (
|
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.vin.message}</p>
|
|
)}
|
|
{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
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Vehicle Specification Dropdowns */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Year
|
|
</label>
|
|
<select
|
|
{...register('year', { valueAsNumber: true })}
|
|
value={watchedYear || ''}
|
|
onChange={(e) => {
|
|
const year = parseInt(e.target.value);
|
|
setValue('year', year);
|
|
}}
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
|
style={{ fontSize: '16px' }}
|
|
>
|
|
<option value="">Select Year</option>
|
|
{years.map((year) => (
|
|
<option key={year} value={year}>
|
|
{year}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Make
|
|
</label>
|
|
<select
|
|
{...register('make')}
|
|
value={watchedMake || ''}
|
|
onChange={(e) => {
|
|
setValue('make', e.target.value);
|
|
}}
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
|
disabled={loadingDropdowns || !watchedYear || makes.length === 0}
|
|
style={{ fontSize: '16px' }}
|
|
>
|
|
<option value="">
|
|
{loadingDropdowns
|
|
? 'Loading...'
|
|
: !watchedYear
|
|
? 'Select year first'
|
|
: makes.length === 0
|
|
? 'No makes available'
|
|
: 'Select Make'}
|
|
</option>
|
|
{makes.map((make) => (
|
|
<option key={make} value={make}>
|
|
{make}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Model
|
|
</label>
|
|
<select
|
|
{...register('model')}
|
|
value={watchedModel || ''}
|
|
onChange={(e) => {
|
|
setValue('model', e.target.value);
|
|
}}
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
|
disabled={loadingDropdowns || !watchedMake || models.length === 0}
|
|
style={{ fontSize: '16px' }}
|
|
>
|
|
<option value="">
|
|
{loadingDropdowns
|
|
? 'Loading...'
|
|
: !watchedMake
|
|
? 'Select make first'
|
|
: models.length === 0
|
|
? 'No models available'
|
|
: 'Select Model'}
|
|
</option>
|
|
{models.map((model) => (
|
|
<option key={model} value={model}>
|
|
{model}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
|
{/* Trim (left) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Trim
|
|
</label>
|
|
<select
|
|
{...register('trimLevel')}
|
|
value={watchedTrim || ''}
|
|
onChange={(e) => {
|
|
setValue('trimLevel', e.target.value);
|
|
}}
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
|
disabled={loadingDropdowns || !watchedModel || trims.length === 0}
|
|
style={{ fontSize: '16px' }}
|
|
>
|
|
<option value="">
|
|
{loadingDropdowns
|
|
? 'Loading...'
|
|
: !watchedModel
|
|
? 'Select model first'
|
|
: trims.length === 0
|
|
? 'No trims available'
|
|
: 'Select Trim'}
|
|
</option>
|
|
{trims.map((trim) => (
|
|
<option key={trim} value={trim}>
|
|
{trim}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Engine (middle) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Engine
|
|
</label>
|
|
<select
|
|
{...register('engine')}
|
|
value={watchedEngine || ''}
|
|
onChange={(e) => {
|
|
setValue('engine', e.target.value);
|
|
}}
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
|
disabled={loadingDropdowns || !watchedTrim || engines.length === 0}
|
|
style={{ fontSize: '16px' }}
|
|
>
|
|
<option value="">
|
|
{loadingDropdowns
|
|
? 'Loading...'
|
|
: !watchedTrim
|
|
? 'Select trim first'
|
|
: engines.length === 0
|
|
? 'N/A (Electric)'
|
|
: 'Select Engine'}
|
|
</option>
|
|
{engines.map((engine) => (
|
|
<option key={engine} value={engine}>
|
|
{engine}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Transmission (right) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Transmission
|
|
</label>
|
|
<select
|
|
{...register('transmission')}
|
|
value={watchedTransmission || ''}
|
|
onChange={(e) => {
|
|
setValue('transmission', e.target.value);
|
|
}}
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
|
disabled={loadingDropdowns || !watchedTrim || transmissions.length === 0}
|
|
style={{ fontSize: '16px' }}
|
|
>
|
|
<option value="">
|
|
{loadingDropdowns
|
|
? 'Loading...'
|
|
: !watchedTrim
|
|
? 'Select trim first'
|
|
: transmissions.length === 0
|
|
? 'No transmissions available'
|
|
: 'Select Transmission'}
|
|
</option>
|
|
{transmissions.map((transmission) => (
|
|
<option key={transmission} value={transmission}>
|
|
{transmission}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Nickname
|
|
</label>
|
|
<input
|
|
{...register('nickname')}
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] 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="e.g., Family Car"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Color
|
|
</label>
|
|
<input
|
|
{...register('color')}
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] 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="e.g., Blue"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
License Plate
|
|
</label>
|
|
<input
|
|
{...register('licensePlate')}
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] 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="e.g., ABC-123 (required if VIN omitted)"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
{errors.licensePlate && (
|
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.licensePlate.message}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Current Odometer Reading
|
|
</label>
|
|
<input
|
|
{...register('odometerReading', { valueAsNumber: true })}
|
|
type="number"
|
|
inputMode="numeric"
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] 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="e.g., 50000"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Purchase Information Section */}
|
|
<div className="border-t border-gray-200 dark:border-silverstone pt-6 mt-6">
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-avus mb-4">
|
|
Purchase Information
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Purchase Price
|
|
</label>
|
|
<input
|
|
{...register('purchasePrice', { valueAsNumber: true })}
|
|
type="number"
|
|
inputMode="decimal"
|
|
step="0.01"
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] 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="e.g., 25000"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
Purchase Date
|
|
</label>
|
|
<input
|
|
{...register('purchaseDate')}
|
|
type="date"
|
|
className="w-full px-3 py-2 border rounded-md min-h-[44px] 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"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3 justify-end pt-4">
|
|
<Button variant="secondary" onClick={onCancel} type="button">
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" loading={loading || loadingDropdowns}>
|
|
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Upgrade Required Dialog for VIN Decode */}
|
|
<UpgradeRequiredDialog
|
|
featureKey="vehicle.vinDecode"
|
|
open={showUpgradeDialog}
|
|
onClose={() => setShowUpgradeDialog(false)}
|
|
/>
|
|
|
|
{/* VIN OCR Review Modal */}
|
|
<VinOcrReviewModal
|
|
open={!!vinOcr.result}
|
|
result={vinOcr.result}
|
|
onAccept={handleAcceptVinOcr}
|
|
onRetake={handleRetakeVinPhoto}
|
|
onClose={vinOcr.reset}
|
|
/>
|
|
</form>
|
|
);
|
|
};
|