diff --git a/hooks/__tests__/useMobileQrScanner.test.ts b/hooks/__tests__/useMobileQrScanner.test.ts new file mode 100644 index 00000000..92d46225 --- /dev/null +++ b/hooks/__tests__/useMobileQrScanner.test.ts @@ -0,0 +1,155 @@ +import { act, renderHook } from '@testing-library/react'; +import jsQR from 'jsqr'; +import { useMobileQrScanner } from '@/hooks/useMobileQrScanner'; + +jest.mock('jsqr', () => ({ __esModule: true, default: jest.fn() })); + +const mockedJsQr = jsQR as jest.MockedFunction; +const stopTrack = jest.fn(); +const stream = { + getTracks: () => [{ stop: stopTrack }], +} as unknown as MediaStream; +const context = { + drawImage: jest.fn(), + getImageData: jest.fn(() => ({ + data: new Uint8ClampedArray(16), + width: 2, + height: 2, + })), +}; + +function videoElement(): HTMLVideoElement { + const video = document.createElement('video'); + Object.defineProperty(video, 'readyState', { + configurable: true, + value: HTMLMediaElement.HAVE_CURRENT_DATA, + }); + Object.defineProperty(video, 'videoWidth', { configurable: true, value: 2 }); + Object.defineProperty(video, 'videoHeight', { configurable: true, value: 2 }); + Object.defineProperty(video, 'srcObject', { + configurable: true, + writable: true, + value: null, + }); + video.play = jest.fn().mockResolvedValue(undefined); + return video; +} + +describe('useMobileQrScanner', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia: jest.fn().mockResolvedValue(stream) }, + }); + jest + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('requests the rear camera with iOS-safe constraints', async () => { + const { result } = renderHook(() => + useMobileQrScanner({ autoStart: false }), + ); + const video = videoElement(); + result.current.videoRef.current = video; + + await act(async () => result.current.startScanning()); + + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ + audio: false, + video: { + facingMode: { ideal: 'environment' }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }); + expect(video.getAttribute('playsinline')).toBe('true'); + expect(result.current.permission).toBe('granted'); + expect(result.current.isScanning).toBe(true); + }); + + it('decodes one frame per second and stops after a result', async () => { + mockedJsQr.mockReturnValueOnce({ + data: 'handoff-123', + binaryData: [], + chunks: [], + location: {} as ReturnType extends infer R + ? R extends { location: infer L } + ? L + : never + : never, + version: 1, + }); + const onDecode = jest.fn(); + const { result } = renderHook(() => + useMobileQrScanner({ autoStart: false, onDecode }), + ); + result.current.videoRef.current = videoElement(); + await act(async () => result.current.startScanning()); + + act(() => { + jest.advanceTimersByTime(999); + }); + expect(mockedJsQr).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1); + }); + + expect(result.current.data).toBe('handoff-123'); + expect(onDecode).toHaveBeenCalledWith('handoff-123'); + expect(result.current.isScanning).toBe(false); + expect(stopTrack).toHaveBeenCalledTimes(1); + }); + + it('reports a denied camera permission', async () => { + const denied = new DOMException('Denied', 'NotAllowedError'); + jest + .mocked(navigator.mediaDevices.getUserMedia) + .mockRejectedValueOnce(denied); + const { result } = renderHook(() => + useMobileQrScanner({ autoStart: false }), + ); + result.current.videoRef.current = videoElement(); + + await act(async () => result.current.startScanning()); + + expect(result.current.permission).toBe('denied'); + expect(result.current.error).toBe('Camera permission was denied.'); + }); + + it('reports unsupported camera APIs without throwing', async () => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: undefined, + }); + const { result } = renderHook(() => + useMobileQrScanner({ autoStart: false }), + ); + + await act(async () => result.current.startScanning()); + + expect(result.current.permission).toBe('unavailable'); + expect(result.current.error).toContain('not available'); + }); + + it('stops every media track on unmount', async () => { + const { result, unmount } = renderHook(() => + useMobileQrScanner({ autoStart: false }), + ); + result.current.videoRef.current = videoElement(); + await act(async () => result.current.startScanning()); + + unmount(); + + expect(stopTrack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/hooks/__tests__/useSignatureCapture.test.ts b/hooks/__tests__/useSignatureCapture.test.ts new file mode 100644 index 00000000..f0b58ee9 --- /dev/null +++ b/hooks/__tests__/useSignatureCapture.test.ts @@ -0,0 +1,168 @@ +import { act, renderHook } from '@testing-library/react'; +import { useSignatureCapture } from '@/hooks/useSignatureCapture'; + +const context = { + beginPath: jest.fn(), + clearRect: jest.fn(), + closePath: jest.fn(), + lineTo: jest.fn(), + moveTo: jest.fn(), + stroke: jest.fn(), + lineCap: '', + lineJoin: '', + lineWidth: 0, + strokeStyle: '', +}; + +function createCanvas(): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = 400; + canvas.height = 160; + jest.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 400, + bottom: 160, + width: 400, + height: 160, + toJSON: () => ({}), + }); + return canvas; +} + +function renderSignatureHook() { + const canvas = createCanvas(); + const hook = renderHook(() => { + const signature = useSignatureCapture(); + signature.canvasRef.current = canvas; + return signature; + }); + return { canvas, hook }; +} + +function touchEvent(type: string, x: number, y: number): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'touches', { + value: type === 'touchend' ? [] : [{ clientX: x, clientY: y }], + }); + Object.defineProperty(event, 'changedTouches', { + value: [{ clientX: x, clientY: y }], + }); + return event; +} + +describe('useSignatureCapture', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + jest + .spyOn(HTMLCanvasElement.prototype, 'toDataURL') + .mockReturnValue('data:image/png;base64,c2lnbmF0dXJl'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('tracks mouse drawing state and ink', () => { + const { canvas, hook } = renderSignatureHook(); + const { result } = hook; + + act(() => { + canvas.dispatchEvent( + new MouseEvent('mousedown', { button: 0, clientX: 10, clientY: 20 }), + ); + }); + expect(result.current.isDrawing).toBe(true); + + act(() => { + canvas.dispatchEvent( + new MouseEvent('mousemove', { clientX: 40, clientY: 50 }), + ); + window.dispatchEvent(new MouseEvent('mouseup')); + }); + + expect(result.current.isDrawing).toBe(false); + expect(result.current.isEmpty()).toBe(false); + expect(context.lineTo).toHaveBeenCalledWith(40, 50); + }); + + it('supports touch drawing without scrolling the page', () => { + const { canvas, hook } = renderSignatureHook(); + const { result } = hook; + const start = touchEvent('touchstart', 12, 18); + const move = touchEvent('touchmove', 30, 45); + + act(() => { + canvas.dispatchEvent(start); + canvas.dispatchEvent(move); + window.dispatchEvent(touchEvent('touchend', 30, 45)); + }); + + expect(start.defaultPrevented).toBe(true); + expect(move.defaultPrevented).toBe(true); + expect(context.stroke).toHaveBeenCalled(); + expect(result.current.isEmpty()).toBe(false); + }); + + it('returns a PNG only after ink has been drawn', () => { + const { canvas, hook } = renderSignatureHook(); + const { result } = hook; + + expect(result.current.save()).toBeNull(); + act(() => { + canvas.dispatchEvent( + new MouseEvent('mousedown', { button: 0, clientX: 1, clientY: 1 }), + ); + canvas.dispatchEvent( + new MouseEvent('mousemove', { clientX: 2, clientY: 2 }), + ); + window.dispatchEvent(new MouseEvent('mouseup')); + }); + + expect(result.current.save()).toBe('data:image/png;base64,c2lnbmF0dXJl'); + expect(canvas.toDataURL).toHaveBeenCalledWith('image/png'); + }); + + it('clears the bitmap and resets state', () => { + const { canvas, hook } = renderSignatureHook(); + const { result } = hook; + act(() => { + canvas.dispatchEvent( + new MouseEvent('mousedown', { button: 0, clientX: 1, clientY: 1 }), + ); + canvas.dispatchEvent( + new MouseEvent('mousemove', { clientX: 2, clientY: 2 }), + ); + result.current.clear(); + }); + + expect(context.clearRect).toHaveBeenCalledWith(0, 0, 400, 160); + expect(result.current.isEmpty()).toBe(true); + expect(result.current.isDrawing).toBe(false); + }); + + it('removes event listeners when the hook unmounts', () => { + const removeCanvasListener = jest.spyOn( + HTMLCanvasElement.prototype, + 'removeEventListener', + ); + const removeWindowListener = jest.spyOn(window, 'removeEventListener'); + const { hook } = renderSignatureHook(); + + hook.unmount(); + + expect(removeCanvasListener).toHaveBeenCalledWith( + 'mousedown', + expect.any(Function), + ); + expect(removeWindowListener).toHaveBeenCalledWith( + 'touchend', + expect.any(Function), + ); + }); +}); diff --git a/hooks/useSignatureCapture.ts b/hooks/useSignatureCapture.ts new file mode 100644 index 00000000..c0ca7060 --- /dev/null +++ b/hooks/useSignatureCapture.ts @@ -0,0 +1,138 @@ +'use client'; + +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +interface Point { + x: number; + y: number; +} + +export interface UseSignatureCaptureOptions { + lineColor?: string; + lineWidth?: number; +} + +export interface UseSignatureCaptureResult { + canvasRef: RefObject; + isDrawing: boolean; + clear: () => void; + save: () => string | null; + isEmpty: () => boolean; +} + +function pointFromEvent( + canvas: HTMLCanvasElement, + event: MouseEvent | TouchEvent, +): Point | null { + const source = + 'touches' in event + ? event.touches[0] ?? event.changedTouches[0] + : event; + if (!source) return null; + + const rect = canvas.getBoundingClientRect(); + const scaleX = rect.width > 0 ? canvas.width / rect.width : 1; + const scaleY = rect.height > 0 ? canvas.height / rect.height : 1; + + return { + x: (source.clientX - rect.left) * scaleX, + y: (source.clientY - rect.top) * scaleY, + }; +} + +export function useSignatureCapture( + options: UseSignatureCaptureOptions = {}, +): UseSignatureCaptureResult { + const { lineColor = '#111827', lineWidth = 2.5 } = options; + const canvasRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const drawingRef = useRef(false); + const emptyRef = useRef(true); + + const clear = useCallback(() => { + const canvas = canvasRef.current; + if (canvas) { + canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height); + } + drawingRef.current = false; + emptyRef.current = true; + setIsDrawing(false); + }, []); + + const save = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || emptyRef.current) return null; + return canvas.toDataURL('image/png'); + }, []); + + const isEmpty = useCallback(() => emptyRef.current, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const context = canvas.getContext('2d'); + if (!context) return; + + context.lineCap = 'round'; + context.lineJoin = 'round'; + context.lineWidth = lineWidth; + context.strokeStyle = lineColor; + canvas.style.touchAction = 'none'; + + const start = (event: MouseEvent | TouchEvent) => { + if (event instanceof MouseEvent && event.button !== 0) return; + const point = pointFromEvent(canvas, event); + if (!point) return; + if (event.cancelable) event.preventDefault(); + context.beginPath(); + context.moveTo(point.x, point.y); + drawingRef.current = true; + setIsDrawing(true); + }; + + const move = (event: MouseEvent | TouchEvent) => { + if (!drawingRef.current) return; + const point = pointFromEvent(canvas, event); + if (!point) return; + if (event.cancelable) event.preventDefault(); + context.lineTo(point.x, point.y); + context.stroke(); + emptyRef.current = false; + }; + + const end = (event: MouseEvent | TouchEvent) => { + if (!drawingRef.current) return; + if (event.cancelable) event.preventDefault(); + context.closePath(); + drawingRef.current = false; + setIsDrawing(false); + }; + + canvas.addEventListener('mousedown', start); + canvas.addEventListener('mousemove', move); + window.addEventListener('mouseup', end); + canvas.addEventListener('touchstart', start, { passive: false }); + canvas.addEventListener('touchmove', move, { passive: false }); + window.addEventListener('touchend', end, { passive: false }); + window.addEventListener('touchcancel', end, { passive: false }); + + return () => { + canvas.removeEventListener('mousedown', start); + canvas.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', end); + canvas.removeEventListener('touchstart', start); + canvas.removeEventListener('touchmove', move); + window.removeEventListener('touchend', end); + window.removeEventListener('touchcancel', end); + drawingRef.current = false; + }; + }, [lineColor, lineWidth]); + + return { canvasRef, isDrawing, clear, save, isEmpty }; +} diff --git a/package.json b/package.json index 9bcc3dc8..f49fea5d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.4.0", "framer-motion": "^12.38.0", + "jsqr": "1.4.0", "leaflet": "^1.9.4", "lodash.debounce": "^4.0.8", "lucide-react": "^1.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5843e07..4bfa96f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: framer-motion: specifier: ^12.38.0 version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + jsqr: + specifier: 1.4.0 + version: 1.4.0 leaflet: specifier: ^1.9.4 version: 1.9.4 @@ -3003,6 +3006,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -7418,6 +7424,8 @@ snapshots: json5@2.2.3: {} + jsqr@1.4.0: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 diff --git a/services/__tests__/vehicleService.test.ts b/services/__tests__/vehicleService.test.ts new file mode 100644 index 00000000..147cbe24 --- /dev/null +++ b/services/__tests__/vehicleService.test.ts @@ -0,0 +1,151 @@ +import axios from 'axios'; +import { apiClient } from '@/services/api'; +import { + VehicleServiceError, + vehicleService, +} from '@/services/vehicleService'; +import { + VehicleType, + type VehicleDetails, + type VehicleRegistrationParams, +} from '@/types/fleet'; + +jest.mock('@/services/api', () => ({ + apiClient: { + delete: jest.fn(), + get: jest.fn(), + patch: jest.fn(), + post: jest.fn(), + }, +})); + +const mockedApiClient = apiClient as jest.Mocked; + +const registration: VehicleRegistrationParams = { + type: VehicleType.VAN, + make: 'Ford', + model: 'Transit', + year: 2025, + registrationNumber: 'REG-123', + licensePlate: 'LAG-123-AB', + capacity: 1_500, + capacityUnit: 'kg', +}; + +const vehicle: VehicleDetails = { + id: 'vehicle/1', + ...registration, + status: 'active', + documents: [], + createdAt: '2026-10-18T10:00:00.000Z', + updatedAt: '2026-10-18T10:00:00.000Z', +}; + +describe('vehicleService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('registers a vehicle', async () => { + const response = { success: true, message: 'Registered', vehicle }; + mockedApiClient.post.mockResolvedValueOnce({ data: response }); + + await expect(vehicleService.registerVehicle(registration)).resolves.toEqual( + response, + ); + expect(mockedApiClient.post).toHaveBeenCalledWith( + '/fleet/vehicles', + registration, + ); + }); + + it('gets a vehicle using an encoded identifier', async () => { + mockedApiClient.get.mockResolvedValueOnce({ data: vehicle }); + + await expect(vehicleService.getVehicle('vehicle/1')).resolves.toEqual( + vehicle, + ); + expect(mockedApiClient.get).toHaveBeenCalledWith( + '/fleet/vehicles/vehicle%2F1', + ); + }); + + it('updates a vehicle', async () => { + const response = { success: true, message: 'Updated', vehicle }; + mockedApiClient.patch.mockResolvedValueOnce({ data: response }); + + await expect( + vehicleService.updateVehicle('vehicle-1', { capacity: 2_000 }), + ).resolves.toEqual(response); + expect(mockedApiClient.patch).toHaveBeenCalledWith( + '/fleet/vehicles/vehicle-1', + { capacity: 2_000 }, + ); + }); + + it('deletes a vehicle', async () => { + const response = { + success: true, + message: 'Deleted', + vehicleId: 'vehicle-1', + }; + mockedApiClient.delete.mockResolvedValueOnce({ data: response }); + + await expect(vehicleService.deleteVehicle('vehicle-1')).resolves.toEqual( + response, + ); + expect(mockedApiClient.delete).toHaveBeenCalledWith( + '/fleet/vehicles/vehicle-1', + ); + }); + + it('uploads a vehicle document as multipart form data', async () => { + const file = new File(['registration'], 'registration.pdf', { + type: 'application/pdf', + }); + const response = { + success: true, + message: 'Uploaded', + document: { + id: 'doc-1', + vehicleId: 'vehicle-1', + type: 'registration' as const, + fileName: file.name, + mimeType: file.type, + size: file.size, + url: '/documents/doc-1', + uploadedAt: '2026-10-18T10:00:00.000Z', + }, + }; + mockedApiClient.post.mockResolvedValueOnce({ data: response }); + + await expect( + vehicleService.uploadVehicleDocument('vehicle-1', file), + ).resolves.toEqual(response); + const formData = mockedApiClient.post.mock.calls[0]?.[1] as FormData; + expect(formData.get('file')).toBe(file); + expect(mockedApiClient.post).toHaveBeenCalledWith( + '/fleet/vehicles/vehicle-1/documents', + expect.any(FormData), + { headers: { 'Content-Type': 'multipart/form-data' } }, + ); + }); + + it('normalizes Axios failures for consumers', async () => { + jest.spyOn(axios, 'isAxiosError').mockReturnValueOnce(true); + mockedApiClient.get.mockRejectedValueOnce({ + message: 'Request failed', + code: 'ERR_BAD_RESPONSE', + response: { status: 422, data: { message: 'Invalid vehicle id' } }, + }); + + await expect(vehicleService.getVehicle('bad')).rejects.toEqual( + expect.objectContaining>({ + name: 'VehicleServiceError', + message: 'Invalid vehicle id', + status: 422, + code: 'ERR_BAD_RESPONSE', + }), + ); + }); +}); diff --git a/services/vehicleService.ts b/services/vehicleService.ts new file mode 100644 index 00000000..19fe3246 --- /dev/null +++ b/services/vehicleService.ts @@ -0,0 +1,107 @@ +import axios from 'axios'; +import { apiClient } from '@/services/api'; +import type { + DeleteResponse, + UpdateResponse, + UploadResponse, + VehicleDetails, + VehicleRegistrationParams, + VehicleRegistrationResponse, +} from '@/types/fleet'; + +interface VehicleApiErrorBody { + error?: unknown; + message?: unknown; +} + +export class VehicleServiceError extends Error { + readonly status?: number; + readonly code?: string; + + constructor(message: string, status?: number, code?: string) { + super(message); + this.name = 'VehicleServiceError'; + this.status = status; + this.code = code; + } +} + +function vehiclePath(vehicleId?: string): string { + const base = '/fleet/vehicles'; + return vehicleId ? `${base}/${encodeURIComponent(vehicleId)}` : base; +} + +function normalizeError(error: unknown): VehicleServiceError { + if (axios.isAxiosError(error)) { + const body = error.response?.data; + const message = + (typeof body?.message === 'string' && body.message) || + (typeof body?.error === 'string' && body.error) || + error.message || + 'Vehicle request failed'; + + return new VehicleServiceError( + message, + error.response?.status, + error.code, + ); + } + + return new VehicleServiceError( + error instanceof Error ? error.message : 'Vehicle request failed', + ); +} + +async function request(operation: () => Promise<{ data: T }>): Promise { + try { + const { data } = await operation(); + return data; + } catch (error) { + throw normalizeError(error); + } +} + +export const vehicleService = { + registerVehicle( + params: VehicleRegistrationParams, + ): Promise { + return request(() => + apiClient.post(vehiclePath(), params), + ); + }, + + getVehicle(vehicleId: string): Promise { + return request(() => apiClient.get(vehiclePath(vehicleId))); + }, + + updateVehicle( + vehicleId: string, + params: Partial, + ): Promise { + return request(() => + apiClient.patch(vehiclePath(vehicleId), params), + ); + }, + + deleteVehicle(vehicleId: string): Promise { + return request(() => + apiClient.delete(vehiclePath(vehicleId)), + ); + }, + + uploadVehicleDocument( + vehicleId: string, + file: File, + ): Promise { + const formData = new FormData(); + formData.append('file', file); + + return request(() => + apiClient.post( + `${vehiclePath(vehicleId)}/documents`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } }, + ), + ); + }, +}; diff --git a/types/fleet.ts b/types/fleet.ts index ea590cb5..74641373 100644 --- a/types/fleet.ts +++ b/types/fleet.ts @@ -1,5 +1,81 @@ export type DriverStatus = 'active' | 'idle' | 'offline' | 'on_delivery'; +/* eslint-disable no-unused-vars -- enum members are the public runtime values */ +export enum VehicleType { + CAR = 'CAR', + BIKE = 'BIKE', + VAN = 'VAN', + TRUCK = 'TRUCK', +} +/* eslint-enable no-unused-vars */ + +export type RegistrationDocumentType = + | 'registration' + | 'insurance' + | 'inspection' + | 'ownership' + | 'other'; + +export interface RegistrationDocument { + id: string; + vehicleId: string; + type: RegistrationDocumentType; + fileName: string; + mimeType: string; + size: number; + url: string; + uploadedAt: string; +} + +export interface VehicleRegistration { + type: VehicleType; + make: string; + model: string; + year: number; + registrationNumber: string; + licensePlate: string; + capacity: number; + capacityUnit: 'kg' | 'tonnes' | 'passengers'; + color?: string; + vin?: string; +} + +export type VehicleRegistrationParams = VehicleRegistration; + +export interface VehicleDetails extends VehicleRegistration { + id: string; + status: 'pending' | 'active' | 'rejected' | 'inactive'; + documents: RegistrationDocument[]; + createdAt: string; + updatedAt: string; +} + +export interface VehicleRegistrationApiResponse { + success: boolean; + message: string; + vehicle: VehicleDetails; +} + +export type VehicleRegistrationResponse = VehicleRegistrationApiResponse; + +export interface UpdateResponse { + success: boolean; + message: string; + vehicle: VehicleDetails; +} + +export interface DeleteResponse { + success: boolean; + message: string; + vehicleId: string; +} + +export interface UploadResponse { + success: boolean; + message: string; + document: RegistrationDocument; +} + export interface DriverLocation { lat: number; lng: number;