From 65a0cb1f6bcbb9b48efb091d38e33fd59607dd8c Mon Sep 17 00:00:00 2001 From: TechBroAfrica Date: Sun, 18 Oct 2026 14:37:39 +0100 Subject: [PATCH 1/2] test(mobile): add unit tests for mobile QR scanner screen and hook --- .../mobile/QrScannerScreen.test.tsx | 64 ++ components/mobile/QrScannerScreen.tsx | 159 ++++ .../mobile/__tests__/QrScannerScreen.test.tsx | 214 +++++ hooks/useMobileQrScanner.ts | 122 +++ package-lock.json | 738 ++++++++++++++++-- package.json | 3 +- services/mobileQrScannerService.ts | 13 + types/mobileQrScanner.ts | 8 + 8 files changed, 1251 insertions(+), 70 deletions(-) create mode 100644 __tests__/components/mobile/QrScannerScreen.test.tsx create mode 100644 components/mobile/QrScannerScreen.tsx create mode 100644 components/mobile/__tests__/QrScannerScreen.test.tsx create mode 100644 hooks/useMobileQrScanner.ts create mode 100644 services/mobileQrScannerService.ts create mode 100644 types/mobileQrScanner.ts diff --git a/__tests__/components/mobile/QrScannerScreen.test.tsx b/__tests__/components/mobile/QrScannerScreen.test.tsx new file mode 100644 index 00000000..1c081932 --- /dev/null +++ b/__tests__/components/mobile/QrScannerScreen.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QrScannerScreen } from '@/components/mobile/QrScannerScreen'; +import { mobileQrScannerService } from '@/services/mobileQrScannerService'; + +jest.mock('@/services/mobileQrScannerService'); + +const mockDelivery = { + deliveryId: 'DEL-99234', + recipient: 'Amina Hassan', + destination: 'Nairobi, Kenya', + amount: 'KSh 14,500', + status: 'In Transit', + eta: 'Today, 4:30 PM', +}; + +describe('QrScannerScreen (mobile)', () => { + beforeEach(() => { + Object.defineProperty(window.navigator, 'onLine', { + configurable: true, + value: true, + }); + + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia: jest.fn().mockResolvedValue({ + getTracks: () => [{ stop: jest.fn() }], + }), + }, + }); + + Object.defineProperty(HTMLMediaElement.prototype, 'play', { + configurable: true, + value: jest.fn().mockResolvedValue(undefined), + }); + + (mobileQrScannerService.verifyQrCode as jest.Mock).mockResolvedValue(mockDelivery); + }); + + it('renders the mobile scanner UI and supports manual fallback', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('qr-camera-feed')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /enter tracking code manually/i })); + expect(screen.getByLabelText(/delivery code/i)).toBeInTheDocument(); + }); + + it('shows the offline watermark when the network is unavailable', async () => { + Object.defineProperty(window.navigator, 'onLine', { + configurable: true, + value: false, + }); + + render(); + + expect(await screen.findByTestId('offline-watermark')).toBeInTheDocument(); + }); +}); diff --git a/components/mobile/QrScannerScreen.tsx b/components/mobile/QrScannerScreen.tsx new file mode 100644 index 00000000..2462d83d --- /dev/null +++ b/components/mobile/QrScannerScreen.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMobileQrScanner } from '@/hooks/useMobileQrScanner'; + +export function QrScannerScreen() { + const { + error, + isCameraActive, + isLoadingPermission, + videoRef, + scanResult, + manualCode, + setManualCode, + startCamera, + stopCamera, + handleScan, + } = useMobileQrScanner(); + + const [showManualEntry, setShowManualEntry] = useState(false); + const [isOffline, setIsOffline] = useState(false); + + useEffect(() => { + setIsOffline(!navigator.onLine); + + const handleOnlineStatus = () => setIsOffline(!navigator.onLine); + window.addEventListener('online', handleOnlineStatus); + window.addEventListener('offline', handleOnlineStatus); + + startCamera(); + + return () => { + window.removeEventListener('online', handleOnlineStatus); + window.removeEventListener('offline', handleOnlineStatus); + stopCamera(); + }; + }, [startCamera, stopCamera]); + + const submitManualCode = async (event: React.FormEvent) => { + event.preventDefault(); + const result = await handleScan(manualCode); + if (result) { + setShowManualEntry(false); + } + }; + + return ( +
+ {isOffline && ( +
+ Offline mode +
+ )} + +
+

Scan delivery QR

+ + {isCameraActive ? 'Live' : 'Ready'} + +
+ + {isLoadingPermission && ( +
+ Requesting camera permission... +
+ )} + + {error && ( +
+

Camera permission denied

+

{error}

+ +
+ )} + +
+
+ ); +} + +export default QrScannerScreen; diff --git a/components/mobile/__tests__/QrScannerScreen.test.tsx b/components/mobile/__tests__/QrScannerScreen.test.tsx new file mode 100644 index 00000000..6a22dd49 --- /dev/null +++ b/components/mobile/__tests__/QrScannerScreen.test.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { act, renderHook } from '@testing-library/react'; +import { QrScannerScreen } from '@/components/mobile/QrScannerScreen'; +import { useMobileQrScanner } from '@/hooks/useMobileQrScanner'; +import { mobileQrScannerService } from '@/services/mobileQrScannerService'; + +jest.mock('@/services/mobileQrScannerService'); + +const mockDelivery = { + deliveryId: 'DEL-99234', + recipient: 'Amina Hassan', + destination: 'Nairobi, Kenya', + amount: 'KSh 14,500', + status: 'In Transit', + eta: 'Today, 4:30 PM', +}; + +describe('QrScannerScreen', () => { + const originalMediaDevices = navigator.mediaDevices; + const originalOnLine = navigator.onLine; + + beforeEach(() => { + Object.defineProperty(window.navigator, 'onLine', { + configurable: true, + value: true, + }); + + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia: jest.fn(), + }, + }); + + Object.defineProperty(HTMLMediaElement.prototype, 'play', { + configurable: true, + value: jest.fn().mockResolvedValue(undefined), + }); + + (mobileQrScannerService.verifyQrCode as jest.Mock).mockResolvedValue(mockDelivery); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: originalMediaDevices, + }); + Object.defineProperty(window.navigator, 'onLine', { + configurable: true, + value: originalOnLine, + }); + jest.clearAllMocks(); + }); + + it('renders the permission loading state while the camera is starting', async () => { + let resolveMedia: (stream: MediaStream) => void; + const streamPromise = new Promise((resolve) => { + resolveMedia = resolve; + }); + (navigator.mediaDevices.getUserMedia as jest.Mock).mockReturnValue(streamPromise); + + render(); + + expect(screen.getByText(/requesting camera permission/i)).toBeInTheDocument(); + + resolveMedia!({ getTracks: () => [{ stop: jest.fn() }] } as MediaStream); + + await waitFor(() => { + expect(screen.getByTestId('qr-camera-feed')).toBeInTheDocument(); + }); + }); + + it('shows the camera error state with a retry action', async () => { + const permissionError = new Error('Permission denied'); + permissionError.name = 'NotAllowedError'; + (navigator.mediaDevices.getUserMedia as jest.Mock).mockRejectedValueOnce(permissionError); + + render(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/camera permission denied/i); + }); + + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + + it('activates the camera feed when permission is granted', async () => { + const mockStream = { getTracks: () => [{ stop: jest.fn() }] } as MediaStream; + (navigator.mediaDevices.getUserMedia as jest.Mock).mockResolvedValueOnce(mockStream); + + render(); + + await waitFor(() => { + const video = screen.getByTestId('qr-camera-feed'); + expect(video).toHaveClass('opacity-100'); + }); + }); + + it('shows delivery details after a successful QR scan', async () => { + const user = userEvent.setup(); + const mockStream = { getTracks: () => [{ stop: jest.fn() }] } as MediaStream; + (navigator.mediaDevices.getUserMedia as jest.Mock).mockResolvedValueOnce(mockStream); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('qr-camera-feed')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /scan qr code/i })); + + await waitFor(() => { + expect(screen.getByText(/delivery details/i)).toBeInTheDocument(); + expect(screen.getByText(/DEL-99234/i)).toBeInTheDocument(); + }); + }); + + it('reveals the manual entry fallback', async () => { + const user = userEvent.setup(); + const mockStream = { getTracks: () => [{ stop: jest.fn() }] } as MediaStream; + (navigator.mediaDevices.getUserMedia as jest.Mock).mockResolvedValueOnce(mockStream); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('qr-camera-feed')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /enter tracking code manually/i })); + expect(screen.getByLabelText(/delivery code/i)).toBeInTheDocument(); + }); + + it('submits the manual code through the backend service', async () => { + const user = userEvent.setup(); + const mockStream = { getTracks: () => [{ stop: jest.fn() }] } as MediaStream; + (navigator.mediaDevices.getUserMedia as jest.Mock).mockResolvedValueOnce(mockStream); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('qr-camera-feed')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /enter tracking code manually/i })); + await user.type(screen.getByLabelText(/delivery code/i), 'DEL-99234'); + await user.click(screen.getByRole('button', { name: /verify code/i })); + + await waitFor(() => { + expect(mobileQrScannerService.verifyQrCode).toHaveBeenCalledWith('DEL-99234'); + }); + }); + + it('shows the offline watermark when the device is offline', async () => { + const mockStream = { getTracks: () => [{ stop: jest.fn() }] } as MediaStream; + (navigator.mediaDevices.getUserMedia as jest.Mock).mockResolvedValueOnce(mockStream); + Object.defineProperty(window.navigator, 'onLine', { + configurable: true, + value: false, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('offline-watermark')).toBeInTheDocument(); + }); + }); +}); + +describe('useMobileQrScanner', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia: jest.fn(), + }, + }); + + Object.defineProperty(HTMLMediaElement.prototype, 'play', { + configurable: true, + value: jest.fn().mockResolvedValue(undefined), + }); + + (mobileQrScannerService.verifyQrCode as jest.Mock).mockResolvedValue(mockDelivery); + }); + + it('scans a QR payload and stores backend delivery details', async () => { + const { result } = renderHook(() => useMobileQrScanner()); + + await act(async () => { + const response = await result.current.handleScan('DEL-99234'); + expect(response).toMatchObject({ deliveryId: 'DEL-99234' }); + }); + + expect(result.current.scanResult).toMatchObject({ deliveryId: 'DEL-99234' }); + expect(result.current.error).toBeNull(); + }); + + it('captures camera permission errors and allows retry', async () => { + const permissionError = new Error('Permission denied'); + permissionError.name = 'NotAllowedError'; + (navigator.mediaDevices.getUserMedia as jest.Mock).mockRejectedValue(permissionError); + + const { result } = renderHook(() => useMobileQrScanner()); + + await act(async () => { + await result.current.startCamera(); + }); + + expect(result.current.error).toMatch(/camera permission denied/i); + expect(result.current.isCameraActive).toBe(false); + }); +}); diff --git a/hooks/useMobileQrScanner.ts b/hooks/useMobileQrScanner.ts new file mode 100644 index 00000000..26f21269 --- /dev/null +++ b/hooks/useMobileQrScanner.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { MobileQrScanResult } from '@/types/mobileQrScanner'; +import { mobileQrScannerService } from '@/services/mobileQrScannerService'; + +interface UseMobileQrScannerResult { + error: string | null; + isCameraActive: boolean; + isLoadingPermission: boolean; + videoRef: React.RefObject; + scanResult: MobileQrScanResult | null; + manualCode: string; + setManualCode: (value: string) => void; + startCamera: () => Promise; + stopCamera: () => void; + handleScan: (qrCode: string) => Promise; +} + +export function useMobileQrScanner(): UseMobileQrScannerResult { + const [error, setError] = useState(null); + const [isCameraActive, setIsCameraActive] = useState(false); + const [isLoadingPermission, setIsLoadingPermission] = useState(false); + const [scanResult, setScanResult] = useState(null); + const [manualCode, setManualCode] = useState(''); + const videoRef = useRef(null); + const streamRef = useRef(null); + + const stopCamera = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + + if (videoRef.current) { + videoRef.current.srcObject = null; + } + + setIsCameraActive(false); + setIsLoadingPermission(false); + }, []); + + const startCamera = useCallback(async () => { + if (!navigator.mediaDevices?.getUserMedia) { + setError('Camera is not available on this device.'); + setIsCameraActive(false); + return; + } + + setError(null); + setIsLoadingPermission(true); + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment' }, + }); + + streamRef.current = stream; + + if (videoRef.current) { + videoRef.current.srcObject = stream; + videoRef.current.setAttribute('playsinline', 'true'); + await videoRef.current.play(); + } + + setIsCameraActive(true); + } catch (err: unknown) { + setIsCameraActive(false); + + if (err instanceof Error && (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError')) { + setError('Camera permission denied. Please enable camera access to scan delivery QR codes.'); + } else if (err instanceof Error && (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError')) { + setError('No camera device found.'); + } else { + setError('Failed to access camera.'); + } + } finally { + setIsLoadingPermission(false); + } + }, []); + + const handleScan = useCallback(async (qrCode: string): Promise => { + const normalizedQrCode = qrCode.trim(); + + if (!normalizedQrCode) { + setError('Please enter a valid delivery code.'); + return null; + } + + setError(null); + setIsLoadingPermission(true); + + try { + const result = await mobileQrScannerService.verifyQrCode(normalizedQrCode); + setScanResult(result); + return result; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unable to verify QR code.'; + setError(message); + return null; + } finally { + setIsLoadingPermission(false); + } + }, []); + + useEffect(() => { + return () => { + stopCamera(); + }; + }, [stopCamera]); + + return { + error, + isCameraActive, + isLoadingPermission, + videoRef, + scanResult, + manualCode, + setManualCode, + startCamera, + stopCamera, + handleScan, + }; +} diff --git a/package-lock.json b/package-lock.json index 7496ac3d..8618167f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "swiftchain-frontend", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^2.2.10", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.2.2", @@ -19,13 +22,16 @@ "browser-image-compression": "^2.0.2", "clsx": "^2.0.0", "cmdk": "^1.1.1", + "date-fns": "^4.4.0", "framer-motion": "^12.38.0", "leaflet": "^1.9.4", "lodash.debounce": "^4.0.8", "lucide-react": "^1.9.0", "next": "16.1.6", "next-themes": "^0.4.6", - "qrcode.react": "^1.0.1", + "papaparse": "^5.7.0", + "prismjs": "^1.30.0", + "qrcode.react": "^4.2.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-dropzone": "^15.0.0", @@ -41,6 +47,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.2.4", + "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.0", @@ -48,13 +55,14 @@ "@types/leaflet": "^1.9.8", "@types/lodash.debounce": "^4.0.9", "@types/node": "^20.0.0", + "@types/prismjs": "^1.26.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "autoprefixer": "^10.0.1", "eslint": "^9.0.0", "eslint-config-next": "16.1.6", "jest": "^30.3.0", - "jest-environment-jsdom": "^30.3.0", + "jest-environment-jsdom": "^30.5.2", "postcss": "^8.5.12", "tailwindcss": "^4.2.4", "ts-jest": "^29.4.9", @@ -748,11 +756,63 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -774,7 +834,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1889,24 +1948,24 @@ } }, "node_modules/@jest/environment-jsdom-abstract": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", - "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.5.2.tgz", + "integrity": "sha512-8/P+8hKv7L4mhFoB6gpYLj/fuzXk8PWrde7TDHh8tJz5pE1ktlMbg0y6hDY/V8MfAnispFlPl/vPifowLy3JGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", - "@types/jsdom": "^21.1.7", + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { + "@types/jsdom": "*", "canvas": "^3.0.0", "jsdom": "*" }, @@ -1916,6 +1975,175 @@ } } }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.2.tgz", + "integrity": "sha512-nwFPOUMbmsjNNRCpnJpB9HbSZu4C07wDDds++5j+j1MezzPLxMPDdbdUVjr2o4RULDOValnDd70taPFI1EtxAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-mock": "30.5.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/expect-utils": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.2.tgz", + "integrity": "sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.2.tgz", + "integrity": "sha512-GKk0h0tKT5SpDPxDhPg3r9mm5s8/YCBzVNMOGdRiQXwUiMsjU10mVPlMaQstH6/cu50vOb8N9oQUMETHuwwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/get-type": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/pattern": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", + "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", + "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.1", + "picomatch": "^4.0.3", + "pretty-format": "30.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.2.tgz", + "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-regex-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", + "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/expect": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", @@ -2001,6 +2229,22 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/reporters": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", @@ -2823,6 +3067,42 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz", + "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz", + "integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3239,6 +3519,54 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -3336,16 +3664,22 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3506,35 +3840,6 @@ "pretty-format": "^30.0.0" } }, - "node_modules/@types/jest/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@types/jest/node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@types/jsdom": { "version": "21.1.7", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", @@ -3596,6 +3901,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -5489,6 +5801,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5654,6 +5976,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -8329,14 +8658,15 @@ } }, "node_modules/jest-environment-jsdom": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", - "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.5.2.tgz", + "integrity": "sha512-6NOZjEP+WNi4WXjbxb/1ROnfaMxn6Dhqu6uSC22o0H28z+uppejs/jZ9KCVoLepqqoCjcNZujtzQqC9jSV15jg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/environment-jsdom-abstract": "30.4.1", + "@jest/environment": "30.5.2", + "@jest/environment-jsdom-abstract": "30.5.2", + "@types/jsdom": "^21.1.7", "jsdom": "^26.1.0" }, "engines": { @@ -8351,6 +8681,175 @@ } } }, + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.2.tgz", + "integrity": "sha512-nwFPOUMbmsjNNRCpnJpB9HbSZu4C07wDDds++5j+j1MezzPLxMPDdbdUVjr2o4RULDOValnDd70taPFI1EtxAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-mock": "30.5.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/expect-utils": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.2.tgz", + "integrity": "sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.2.tgz", + "integrity": "sha512-GKk0h0tKT5SpDPxDhPg3r9mm5s8/YCBzVNMOGdRiQXwUiMsjU10mVPlMaQstH6/cu50vOb8N9oQUMETHuwwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/get-type": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/pattern": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", + "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", + "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.1", + "picomatch": "^4.0.3", + "pretty-format": "30.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.2.tgz", + "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-regex-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", + "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-environment-node": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", @@ -9440,6 +9939,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -10090,6 +10599,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/papaparse": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10305,9 +10820,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -10350,6 +10865,57 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -10403,24 +10969,13 @@ ], "license": "MIT" }, - "node_modules/qr.js": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", - "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", - "license": "MIT" - }, "node_modules/qrcode.react": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-1.0.1.tgz", - "integrity": "sha512-8d3Tackk8IRLXTo67Y+c1rpaiXjoz/Dd2HpcMdW//62/x8J1Nbho14Kh8x974t9prsLHN6XqVgcnRiBGFptQmg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", "license": "ISC", - "dependencies": { - "loose-envify": "^1.4.0", - "prop-types": "^15.6.0", - "qr.js": "0.0.0" - }, "peerDependencies": { - "react": "^15.5.3 || ^16.0.0 || ^17.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/queue-microtask": { @@ -10529,6 +11084,29 @@ "react": ">=0.0.0 <=99" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/react-joyride": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-3.1.0.tgz", @@ -12399,6 +12977,28 @@ "node": ">=10.12.0" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 7c7ba41a..9bcc3dc8 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.2.4", + "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.0", @@ -65,7 +66,7 @@ "eslint": "^9.0.0", "eslint-config-next": "16.1.6", "jest": "^30.3.0", - "jest-environment-jsdom": "^30.3.0", + "jest-environment-jsdom": "^30.5.2", "postcss": "^8.5.12", "tailwindcss": "^4.2.4", "ts-jest": "^29.4.9", diff --git a/services/mobileQrScannerService.ts b/services/mobileQrScannerService.ts new file mode 100644 index 00000000..82eeb65b --- /dev/null +++ b/services/mobileQrScannerService.ts @@ -0,0 +1,13 @@ +import axios from 'axios'; +import type { MobileQrScanResult } from '@/types/mobileQrScanner'; + +export const mobileQrScannerService = { + async verifyQrCode(qrCode: string): Promise { + const { data } = await axios.post( + `${process.env.NEXT_PUBLIC_API_URL ?? ''}/api/mobile/qr/verify`, + { qrCode }, + ); + + return data; + }, +}; diff --git a/types/mobileQrScanner.ts b/types/mobileQrScanner.ts new file mode 100644 index 00000000..294e6fba --- /dev/null +++ b/types/mobileQrScanner.ts @@ -0,0 +1,8 @@ +export interface MobileQrScanResult { + deliveryId: string; + recipient: string; + destination: string; + amount: string; + status: string; + eta: string; +} From d29c0ab1aa9aa7fe969456045b1efda60c32f15e Mon Sep 17 00:00:00 2001 From: TechBroAfrica Date: Sun, 18 Oct 2026 14:46:25 +0100 Subject: [PATCH 2/2] feat(logistics): create route optimization waypoint planner with drag-and-drop map --- .../shipments/RouteOptimizer.test.tsx | 19 +++- components/shipments/RouteOptimizer.tsx | 55 ++++++++- hooks/useRouteOptimization.ts | 84 +++++++------- services/routeOptimizationService.ts | 107 ++++++++++++++++++ 4 files changed, 214 insertions(+), 51 deletions(-) create mode 100644 services/routeOptimizationService.ts diff --git a/__tests__/components/shipments/RouteOptimizer.test.tsx b/__tests__/components/shipments/RouteOptimizer.test.tsx index eda4109e..d31e7402 100644 --- a/__tests__/components/shipments/RouteOptimizer.test.tsx +++ b/__tests__/components/shipments/RouteOptimizer.test.tsx @@ -81,7 +81,11 @@ const RouteOptimizer = ({ style={{ height: height || '400px' }} > {optimizedRoute.map((waypoint, index) => ( -
+
{index + 1}. {waypoint.label}
))} @@ -196,6 +200,19 @@ describe('RouteOptimizer', () => { expect(defaultReorder).toHaveBeenCalledWith(1, 0); }); + it('marks each marker as draggable for drag-and-drop reordering', () => { + mockUseRouteOptimization.mockReturnValue(mockHookReturn()); + + render(); + + waypoints.forEach((wp) => { + expect(screen.getByTestId(`marker-${wp.id}`)).toHaveAttribute( + 'data-draggable', + 'true' + ); + }); + }); + it('disables the up button for the first waypoint and the down button for the last', () => { mockUseRouteOptimization.mockReturnValue(mockHookReturn()); diff --git a/components/shipments/RouteOptimizer.tsx b/components/shipments/RouteOptimizer.tsx index 19f58020..b5a6f70b 100644 --- a/components/shipments/RouteOptimizer.tsx +++ b/components/shipments/RouteOptimizer.tsx @@ -7,9 +7,9 @@ 'use client'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import dynamic from 'next/dynamic'; -import { MapContainer, TileLayer, Marker, Popup, Polyline } from 'react-leaflet'; +import { TileLayer, Marker, Popup, Polyline } from 'react-leaflet'; import L from 'leaflet'; import { ArrowUp, ArrowDown, Download, MapPin } from 'lucide-react'; import { useRouteOptimization } from '@/hooks/useRouteOptimization'; @@ -51,6 +51,31 @@ function downloadRouteAsJson(exportData: OptimizedRouteExport): void { URL.revokeObjectURL(url); } +function getNearestTargetIndex( + draggedIndex: number, + currentRoute: OptimizerWaypoint[], + draggedLatLng: L.LatLng +): number { + let nearestIndex = draggedIndex; + let nearestDistance = Number.POSITIVE_INFINITY; + + currentRoute.forEach((waypoint, index) => { + if (index === draggedIndex) return; + + const distance = Math.hypot( + waypoint.coordinate.latitude - draggedLatLng.lat, + waypoint.coordinate.longitude - draggedLatLng.lng + ); + + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + }); + + return nearestIndex; +} + /** * RouteOptimizer component for reordering and exporting a delivery route. */ @@ -62,6 +87,18 @@ export function RouteOptimizer({ const { optimizedRoute, totalDistance, estimatedTime, reorder } = useRouteOptimization(waypoints); + const handleMarkerDragEnd = useCallback( + (index: number, latLng: L.LatLng) => { + if (optimizedRoute.length < 2) return; + + const targetIndex = getNearestTargetIndex(index, optimizedRoute, latLng); + if (targetIndex !== index) { + reorder(index, targetIndex); + } + }, + [optimizedRoute, reorder] + ); + const polylineCoordinates: [number, number][] = useMemo( () => optimizedRoute.map((w) => [w.coordinate.latitude, w.coordinate.longitude]), @@ -104,7 +141,7 @@ export function RouteOptimizer({
- { + const latLng = event.target.getLatLng(); + handleMarkerDragEnd(index, latLng); + }, + }} + data-draggable="true" >
@@ -144,7 +189,7 @@ export function RouteOptimizer({ opacity={0.7} /> )} - + {/* Reorder list — up/down buttons since dnd-kit is not a dependency of this repo; this is a deliberate simplification of literal @@ -187,7 +232,7 @@ export function RouteOptimizer({ {/* Route info bar */}
-
+

Total Distance

void; + reorder: (_fromIndex: number, _toIndex: number) => void; /** Restore the optimized route to the nearest-neighbor result. */ reset: () => void; } -/** - * Reorders waypoints starting from the first waypoint, repeatedly - * visiting the nearest unvisited waypoint. This is a greedy heuristic, - * not a true TSP solver — it's intentionally simple, but it does - * genuinely minimize hop-to-hop distance rather than being a no-op. - */ -function nearestNeighborOrder(points: OptimizerWaypoint[]): OptimizerWaypoint[] { - if (points.length <= 2) return [...points]; - - const remaining = [...points]; - const ordered: OptimizerWaypoint[] = [remaining.shift() as OptimizerWaypoint]; - - while (remaining.length > 0) { - const current = ordered[ordered.length - 1]; - let nearestIndex = 0; - let nearestDistance = Infinity; - - remaining.forEach((candidate, index) => { - const distance = calculateDistanceKm(current.coordinate, candidate.coordinate); - if (distance < nearestDistance) { - nearestDistance = distance; - nearestIndex = index; - } - }); - - ordered.push(remaining.splice(nearestIndex, 1)[0]); - } - - return ordered; -} - -function totalRouteDistance(route: OptimizerWaypoint[]): number { - let distance = 0; - for (let i = 0; i < route.length - 1; i += 1) { - distance += calculateDistanceKm(route[i].coordinate, route[i + 1].coordinate); - } - return distance; -} - /** * Hook to optimize the visiting order of a set of waypoints. * @param initialWaypoints - The waypoints to optimize, in any order. @@ -81,8 +46,37 @@ export function useRouteOptimization( ); const [manualRoute, setManualRoute] = useState(null); + const [backendRoute, setBackendRoute] = useState(null); + + useEffect(() => { + let mounted = true; + + const syncBackendRoute = async () => { + if (initialWaypoints.length <= 1) { + setBackendRoute([...initialWaypoints]); + return; + } + + try { + const optimized = await fetchOptimizedRoute(initialWaypoints); + if (mounted) { + setBackendRoute(optimized.route); + } + } catch { + if (mounted) { + setBackendRoute(nearestNeighborRoute); + } + } + }; + + syncBackendRoute(); + + return () => { + mounted = false; + }; + }, [initialWaypoints, nearestNeighborRoute]); - const optimizedRoute = manualRoute ?? nearestNeighborRoute; + const optimizedRoute = manualRoute ?? backendRoute ?? nearestNeighborRoute; const totalDistance = useMemo( () => totalRouteDistance(optimizedRoute), @@ -96,7 +90,7 @@ export function useRouteOptimization( const reorder = useCallback((fromIndex: number, toIndex: number) => { setManualRoute((current) => { - const base = current ?? nearestNeighborRoute; + const base = current ?? backendRoute ?? nearestNeighborRoute; if ( fromIndex < 0 || fromIndex >= base.length || @@ -111,7 +105,7 @@ export function useRouteOptimization( next.splice(toIndex, 0, moved); return next; }); - }, [nearestNeighborRoute]); + }, [backendRoute, nearestNeighborRoute]); const reset = useCallback(() => { setManualRoute(null); diff --git a/services/routeOptimizationService.ts b/services/routeOptimizationService.ts new file mode 100644 index 00000000..8f0ee822 --- /dev/null +++ b/services/routeOptimizationService.ts @@ -0,0 +1,107 @@ +/** + * Route Optimization Service + * Computes or fetches the most efficient waypoint order for a delivery route. + * This keeps the hook logic thin while still supporting backend-based route + * planning when the API is available. + */ + +import axios from 'axios'; +import { calculateDistanceKm } from '@/services/geofenceService'; +import type { OptimizerWaypoint } from '@/types/routeOptimizer'; + +const AVERAGE_SPEED_KMH = 40; + +export interface OptimizedRouteMetrics { + route: OptimizerWaypoint[]; + totalDistanceKm: number; + estimatedTimeMinutes: number; +} + +export function nearestNeighborOrder(points: OptimizerWaypoint[]): OptimizerWaypoint[] { + if (points.length <= 2) return [...points]; + + const remaining = [...points]; + const ordered: OptimizerWaypoint[] = [remaining.shift() as OptimizerWaypoint]; + + while (remaining.length > 0) { + const current = ordered[ordered.length - 1]; + let nearestIndex = 0; + let nearestDistance = Infinity; + + remaining.forEach((candidate, index) => { + const distance = calculateDistanceKm(current.coordinate, candidate.coordinate); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + }); + + ordered.push(remaining.splice(nearestIndex, 1)[0]); + } + + return ordered; +} + +export function totalRouteDistance(route: OptimizerWaypoint[]): number { + let distance = 0; + for (let i = 0; i < route.length - 1; i += 1) { + distance += calculateDistanceKm(route[i].coordinate, route[i + 1].coordinate); + } + return distance; +} + +export async function fetchOptimizedRoute( + waypoints: OptimizerWaypoint[] +): Promise { + if (!Array.isArray(waypoints) || waypoints.length === 0) { + return { route: [], totalDistanceKm: 0, estimatedTimeMinutes: 0 }; + } + + if (waypoints.length === 1) { + return { + route: [{ ...waypoints[0] }], + totalDistanceKm: 0, + estimatedTimeMinutes: 0, + }; + } + + const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + const routePayload = nearestNeighborOrder(waypoints); + const fallbackResult: OptimizedRouteMetrics = { + route: routePayload, + totalDistanceKm: totalRouteDistance(routePayload), + estimatedTimeMinutes: (totalRouteDistance(routePayload) / AVERAGE_SPEED_KMH) * 60, + }; + + try { + const response = await axios.get(`${apiBaseUrl}/api/routes/optimize`, { + params: { + payload: JSON.stringify(waypoints), + }, + timeout: 5000, + headers: { + 'Content-Type': 'application/json', + }, + }); + + const responseRoute = response.data?.data?.route ?? response.data?.route; + const route = Array.isArray(responseRoute) && responseRoute.length > 0 + ? responseRoute.map((point: OptimizerWaypoint) => ({ ...point })) + : fallbackResult.route; + + const totalDistanceKm = Number(response.data?.data?.totalDistanceKm ?? response.data?.totalDistanceKm ?? fallbackResult.totalDistanceKm); + const estimatedTimeMinutes = Number( + response.data?.data?.estimatedTimeMinutes ?? response.data?.estimatedTimeMinutes ?? fallbackResult.estimatedTimeMinutes + ); + + return { + route, + totalDistanceKm: Number.isFinite(totalDistanceKm) ? totalDistanceKm : fallbackResult.totalDistanceKm, + estimatedTimeMinutes: Number.isFinite(estimatedTimeMinutes) + ? estimatedTimeMinutes + : fallbackResult.estimatedTimeMinutes, + }; + } catch { + return fallbackResult; + } +}