diff --git a/package.json b/package.json index 025e086f..822fb3c8 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-big-calendar": "1.19.4", + "jsqr": "^1.4.0", "react-countdown": "^2.3.6", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6e32145..2d8bd7ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: idb: specifier: ^8.0.0 version: 8.0.3 + jsqr: + specifier: ^1.4.0 + version: 1.4.0 lucide-react: specifier: ^0.462.0 version: 0.462.0(react@18.3.1) @@ -5485,6 +5488,9 @@ packages: jsontokens@4.0.1: resolution: {integrity: sha512-+MO415LEN6M+3FGsRz4wU20g7N2JA+2j9d9+pGaNJHviG4L8N0qzavGyENw6fJqsq9CcrHOIL6iWX5yeTZ86+Q==} + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -14270,6 +14276,8 @@ snapshots: '@noble/secp256k1': 1.7.2 base64-js: 1.5.1 + jsqr@1.4.0: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 diff --git a/src/components/mobile/MobileNavigation.tsx b/src/components/mobile/MobileNavigation.tsx index 48a71ff0..8084bcd3 100644 --- a/src/components/mobile/MobileNavigation.tsx +++ b/src/components/mobile/MobileNavigation.tsx @@ -1,8 +1,8 @@ 'use client'; import React, { useState, useEffect, useRef } from 'react'; -import { Home, Search, BookOpen, User } from 'lucide-react'; -import { motion } from 'framer-motion'; +import { Home, Search, BookOpen, User, Camera } from 'lucide-react'; +import { MobileNavigationScanner } from './MobileNavigationScanner'; interface NavItem { id: string; @@ -49,8 +49,12 @@ export const MobileNavigation: React.FC<{ const [activeTab, setActiveTab] = useState(initialActive); const [isFloating, setIsFloating] = useState(false); const [isLandscape, setIsLandscape] = useState(false); + const [isScannerOpen, setIsScannerOpen] = useState(false); const navRef = useRef(null); + const openScanner = () => setIsScannerOpen(true); + const closeScanner = () => setIsScannerOpen(false); + useEffect(() => { const handleResize = () => { setIsFloating(window.innerWidth >= 640); @@ -191,6 +195,18 @@ export const MobileNavigation: React.FC<{ ); })} +
+ +
+ ); }; diff --git a/src/components/mobile/MobileNavigationScanner.tsx b/src/components/mobile/MobileNavigationScanner.tsx new file mode 100644 index 00000000..211a9436 --- /dev/null +++ b/src/components/mobile/MobileNavigationScanner.tsx @@ -0,0 +1,301 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import { Camera, Upload, CheckCircle2, AlertTriangle } from 'lucide-react'; +import { Modal } from '@/components/ui/Modal'; +import { useToast } from '@/context/ToastContext'; +import jsQR from 'jsqr'; + +interface MobileNavigationScannerProps { + isOpen: boolean; + onClose: () => void; +} + +const INITIAL_MESSAGE = 'Choose camera scan or upload an image file to detect a QR code.'; + +export function MobileNavigationScanner({ isOpen, onClose }: MobileNavigationScannerProps) { + const [status, setStatus] = useState<'idle' | 'requesting' | 'scanning' | 'success' | 'failure'>('idle'); + const [feedbackMessage, setFeedbackMessage] = useState(INITIAL_MESSAGE); + const [scanResult, setScanResult] = useState(null); + const [cameraSupported, setCameraSupported] = useState(false); + + const videoRef = useRef(null); + const canvasRef = useRef(null); + const fileInputRef = useRef(null); + const streamRef = useRef(null); + const frameRequestRef = useRef(null); + + const { success, error } = useToast(); + + useEffect(() => { + setCameraSupported(!!navigator.mediaDevices?.getUserMedia); + }, []); + + useEffect(() => { + if (!isOpen) { + stopCamera(); + resetScanner(); + } + + return () => { + stopCamera(); + }; + }, [isOpen]); + + const resetScanner = () => { + setStatus('idle'); + setFeedbackMessage(INITIAL_MESSAGE); + setScanResult(null); + }; + + const stopCamera = () => { + if (frameRequestRef.current !== null) { + cancelAnimationFrame(frameRequestRef.current); + frameRequestRef.current = null; + } + + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + + if (videoRef.current) { + videoRef.current.srcObject = null; + } + }; + + const handleScanSuccess = (data: string) => { + stopCamera(); + setStatus('success'); + setScanResult(data); + setFeedbackMessage('Scan successful.'); + success(`Scanned: ${data}`); + }; + + const handleCameraError = (reason: unknown) => { + const message = + reason instanceof Error + ? reason.message + : 'Camera permission denied or camera is unavailable.'; + + setStatus('failure'); + setFeedbackMessage('Camera access is unavailable. You may use image upload instead.'); + error(message); + }; + + const decodeFrame = () => { + const video = videoRef.current; + const canvas = canvasRef.current; + if (!video || !canvas) { + return null; + } + + const width = video.videoWidth || video.clientWidth || 640; + const height = video.videoHeight || video.clientHeight || 480; + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + + context.drawImage(video, 0, 0, width, height); + const imageData = context.getImageData(0, 0, width, height); + return jsQR(imageData.data, imageData.width, imageData.height); + }; + + const scanVideoFrame = () => { + if (!videoRef.current || !canvasRef.current) { + return; + } + + if (videoRef.current.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + frameRequestRef.current = requestAnimationFrame(scanVideoFrame); + return; + } + + const code = decodeFrame(); + if (code?.data) { + handleScanSuccess(code.data); + return; + } + + setStatus('scanning'); + setFeedbackMessage('Scanning. Hold your device steadily over a QR code.'); + frameRequestRef.current = requestAnimationFrame(scanVideoFrame); + }; + + const handleStartCamera = async () => { + if (!navigator.mediaDevices?.getUserMedia) { + handleCameraError(new Error('Camera is not supported by this browser.')); + return; + } + + setStatus('requesting'); + setFeedbackMessage('Requesting camera permission...'); + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + }, + }); + + streamRef.current = stream; + if (videoRef.current) { + videoRef.current.srcObject = stream; + await videoRef.current.play(); + } + + setStatus('scanning'); + setFeedbackMessage('Camera active. Point the camera at a QR code to scan.'); + frameRequestRef.current = requestAnimationFrame(scanVideoFrame); + } catch (err) { + handleCameraError(err); + } + }; + + const handleImageUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + setStatus('scanning'); + setFeedbackMessage('Scanning uploaded image...'); + const objectUrl = URL.createObjectURL(file); + const image = new Image(); + + image.onload = () => { + const canvas = canvasRef.current; + if (!canvas) { + URL.revokeObjectURL(objectUrl); + return; + } + + const ratio = Math.min(1, 1024 / image.width); + const width = Math.max(320, Math.round(image.width * ratio)); + const height = Math.max(240, Math.round(image.height * ratio)); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + if (!context) { + URL.revokeObjectURL(objectUrl); + return; + } + + context.clearRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + const imageData = context.getImageData(0, 0, width, height); + const code = jsQR(imageData.data, imageData.width, imageData.height); + URL.revokeObjectURL(objectUrl); + + if (code?.data) { + handleScanSuccess(code.data); + } else { + setStatus('failure'); + setFeedbackMessage('No QR code detected in the uploaded image.'); + error('No QR code found in the uploaded image.'); + } + }; + + image.onerror = () => { + URL.revokeObjectURL(objectUrl); + setStatus('failure'); + setFeedbackMessage('Unable to load the selected image file.'); + error('Unable to read the uploaded image.'); + }; + + image.src = objectUrl; + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + return ( + +
+
+
+
+

Camera scan works best for QR codes, with image upload as a fallback when permissions are unavailable.

+
+ +
+ + {!cameraSupported && ( +

+ Camera access is not supported in this browser. Use the upload fallback instead. +

+ )} + +
+ +
+
+
+

Scan status

+

{feedbackMessage}

+
+
+ {status === 'success' ? : status === 'failure' ? : } + {status === 'success' ? 'Success' : status === 'failure' ? 'Error' : status === 'scanning' ? 'Scanning' : 'Ready'} +
+
+ +
+
+
+ + {scanResult && ( +
+ QR code found: {scanResult} +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/mobile/__tests__/MobileNavigation.test.tsx b/src/components/mobile/__tests__/MobileNavigation.test.tsx index 0d307605..e9ff43d2 100644 --- a/src/components/mobile/__tests__/MobileNavigation.test.tsx +++ b/src/components/mobile/__tests__/MobileNavigation.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ToastProvider } from '@/context/ToastContext'; import { MobileNavigation } from '../MobileNavigation'; describe('MobileNavigation Component', () => { @@ -11,7 +12,11 @@ describe('MobileNavigation Component', () => { }); it('renders all navigation items correctly', () => { - render(); + render( + + + , + ); expect(screen.getByRole('navigation', { name: /mobile navigation/i })).toBeInTheDocument(); expect(screen.getByRole('tablist', { name: /navigation tabs/i })).toBeInTheDocument(); @@ -26,7 +31,11 @@ describe('MobileNavigation Component', () => { }); it('handles initial active tab setting correctly', () => { - render(); + render( + + + , + ); expect(screen.getByRole('tab', { name: /home/i })).toHaveAttribute('aria-selected', 'false'); expect(screen.getByRole('tab', { name: /courses/i })).toHaveAttribute('aria-selected', 'true'); @@ -38,7 +47,11 @@ describe('MobileNavigation Component', () => { it('triggers onNavChange and updates active tab state on click', async () => { const user = userEvent.setup(); - render(); + render( + + + , + ); const searchTab = screen.getByRole('tab', { name: /search/i }); const homeTab = screen.getByRole('tab', { name: /home/i }); @@ -54,10 +67,31 @@ describe('MobileNavigation Component', () => { expect(homeTab).toHaveAttribute('aria-selected', 'false'); }); + it('shows the scanner button and opens the scanner dialog', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const scannerButton = screen.getByRole('button', { name: /open mobile scanner/i }); + expect(scannerButton).toBeInTheDocument(); + + await user.click(scannerButton); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /mobile scanner/i })).toBeInTheDocument(); + }); + describe('Keyboard Navigation (WAI-ARIA Tablist Compliance)', () => { it('moves focus to the next item when ArrowRight or ArrowDown is pressed', async () => { const user = userEvent.setup(); - render(); + render( + + + , + ); const homeTab = screen.getByRole('tab', { name: /home/i }); const searchTab = screen.getByRole('tab', { name: /search/i }); @@ -74,7 +108,11 @@ describe('MobileNavigation Component', () => { it('moves focus to the previous item when ArrowLeft or ArrowUp is pressed', async () => { const user = userEvent.setup(); - render(); + render( + + + , + ); const searchTab = screen.getByRole('tab', { name: /search/i }); const homeTab = screen.getByRole('tab', { name: /home/i }); @@ -95,7 +133,11 @@ describe('MobileNavigation Component', () => { it('moves focus to first and last items on Home and End keys', async () => { const user = userEvent.setup(); - render(); + render( + + + , + ); const searchTab = screen.getByRole('tab', { name: /search/i }); const homeTab = screen.getByRole('tab', { name: /home/i }); @@ -113,7 +155,11 @@ describe('MobileNavigation Component', () => { describe('Responsive Design Styling', () => { it('applies bottom bar classes by default for compact portrait screens', () => { - render(); + render( + + + , + ); const nav = screen.getByRole('navigation', { name: /mobile navigation/i }); const classList = nav.className; @@ -128,7 +174,11 @@ describe('MobileNavigation Component', () => { }); it('only switches to a side rail at landscape mobile/tablet dimensions', () => { - render(); + render( + + + , + ); const nav = screen.getByRole('navigation', { name: /mobile navigation/i }); const classList = nav.className; @@ -141,7 +191,11 @@ describe('MobileNavigation Component', () => { }); it('has standard safe-area padding for notches and interactive boundaries', () => { - render(); + render( + + + , + ); const nav = screen.getByRole('navigation', { name: /mobile navigation/i }); const styleAttr = nav.getAttribute('style') || ''; @@ -152,7 +206,11 @@ describe('MobileNavigation Component', () => { }); it('keeps labels visible in the bottom bar and hides them in the side rail', () => { - render(); + render( + + + , + ); const label = screen.getByText('Home'); const responsiveRailPrefix = '[@media_(min-width:640px)_and_(orientation:landscape)]'; diff --git a/src/components/mobile/__tests__/MobileNavigationScanner.test.tsx b/src/components/mobile/__tests__/MobileNavigationScanner.test.tsx new file mode 100644 index 00000000..e585b578 --- /dev/null +++ b/src/components/mobile/__tests__/MobileNavigationScanner.test.tsx @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, beforeAll, afterAll, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ToastProvider } from '@/context/ToastContext'; +import { MobileNavigationScanner } from '../MobileNavigationScanner'; +import jsQR from 'jsqr'; + +vi.mock('jsqr', () => ({ __esModule: true, default: vi.fn() })); + +beforeAll(() => { + // Mock URL helpers used when creating object URLs for uploaded images + // Provide deterministic values for tests + // @ts-ignore + global.URL.createObjectURL = vi.fn(() => 'mock-url'); + // @ts-ignore + global.URL.revokeObjectURL = vi.fn(); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe('MobileNavigationScanner Component', () => { + const originalMediaDevices = navigator.mediaDevices; + const originalImage = global.Image; + + beforeEach(() => { + vi.resetAllMocks(); + + Object.defineProperty(global, 'Image', { + configurable: true, + writable: true, + value: class { + onload?: () => void; + onerror?: () => void; + width = 100; + height = 100; + set src(_src: string) { + this.onload?.(); + } + }, + }); + + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: vi.fn(() => ({ + drawImage: vi.fn(), + clearRect: vi.fn(), + getImageData: vi.fn(() => ({ + data: new Uint8ClampedArray([0, 0, 0, 0]), + width: 1, + height: 1, + })), + })), + }); + }); + + afterEach(() => { + Object.defineProperty(global, 'Image', { + configurable: true, + writable: true, + value: originalImage, + }); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + writable: true, + value: originalMediaDevices, + }); + vi.restoreAllMocks(); + }); + + function renderScanner() { + return render( + + + , + ); + } + + it('renders the scanner dialog when open', () => { + renderScanner(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /mobile scanner/i })).toBeInTheDocument(); + }); + + it('shows a camera permission fallback when camera access is unavailable', async () => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + writable: true, + value: undefined, + }); + + renderScanner(); + + await userEvent.click(screen.getByRole('button', { name: /start camera scan/i })); + + expect(await screen.findByText(/camera access is unavailable/i)).toBeInTheDocument(); + expect(screen.getByText(/use image upload instead/i)).toBeInTheDocument(); + }); + + it('uploads an image and shows a successful QR scan result', async () => { + // jsQR is mocked as an ES module default; the imported `jsQR` is the mock function itself. + (jsQR as unknown as any).mockReturnValue({ data: 'TEST-QR' }); + + renderScanner(); + + const uploadInput = screen.getByLabelText(/Upload QR image/i) as HTMLInputElement; + const file = new File(['dummy'], 'qrcode.png', { type: 'image/png' }); + + await userEvent.upload(uploadInput, file); + + await waitFor(() => { + // Toast shows scanned message + expect(screen.getByText(/scanned: TEST-QR/i)).toBeInTheDocument(); + }); + }); +});