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}
+
+
+ )}
+
+
+
+
+ {!isCameraActive && !error && !isLoadingPermission && (
+
+ Camera is warming up...
+
+ )}
+
+ {isCameraActive && (
+
+ )}
+
+
+ {scanResult && (
+
+
Delivery details
+
+ - Delivery ID: {scanResult.deliveryId}
+ - Recipient: {scanResult.recipient}
+ - Destination: {scanResult.destination}
+ - Amount: {scanResult.amount}
+ - Status: {scanResult.status}
+ - ETA: {scanResult.eta}
+
+
+ )}
+
+
+
+
+
+
+
+ {showManualEntry && (
+
+ )}
+
+ );
+}
+
+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;
+}