From f3c7bf8e3d113e593892678239670ccf35a3cdfe Mon Sep 17 00:00:00 2001 From: helenahelenavanbeek-png Date: Sat, 30 May 2026 23:09:57 +0000 Subject: [PATCH] feat: add Image Scanning to Mobile Navigation (Closes #470) - Add MobileNavigationScanner component with camera/QR scanning - Integrate scan button into mobile navigation - Add jsqr library for QR decoding - Add unit tests with ToastProvider wrapper - Fix test mocks for jsQR and URL.createObjectURL - Handle camera permissions and image upload fallback --- package-lock.json | 300 ++++++++++++++++- package.json | 1 + pnpm-lock.yaml | 95 +++++- src/components/mobile/MobileNavigation.tsx | 20 +- .../mobile/MobileNavigationScanner.tsx | 301 ++++++++++++++++++ .../__tests__/MobileNavigation.test.tsx | 78 ++++- .../MobileNavigationScanner.test.tsx | 117 +++++++ 7 files changed, 884 insertions(+), 28 deletions(-) create mode 100644 src/components/mobile/MobileNavigationScanner.tsx create mode 100644 src/components/mobile/__tests__/MobileNavigationScanner.test.tsx diff --git a/package-lock.json b/package-lock.json index fc2a4d2a..c45b402c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "dompurify": "^3.2.4", + "ethers": "^6.12.0", "framer-motion": "^12.23.0", "graphql": "^16.8.0", "graphql-ws": "^5.14.0", @@ -6238,9 +6239,12 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "optional": true, + "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -9465,6 +9469,11 @@ "pkcs7": "^1.0.4" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -10831,6 +10840,14 @@ "lodash.clonedeep": "^4.5.0" } }, + "node_modules/chessify-protocol/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/chessify-protocol/node_modules/next": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", @@ -12821,6 +12838,98 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==" + }, + "node_modules/ethers/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -16625,6 +16734,14 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/next/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -26840,9 +26957,12 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "optional": true, + "peer": true, "requires": { "tslib": "^2.8.0" } @@ -28910,6 +29030,11 @@ "pkcs7": "^1.0.4" } }, + "aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==" + }, "agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -29866,6 +29991,14 @@ "lodash.clonedeep": "^4.5.0" } }, + "@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "requires": { + "tslib": "^2.8.0" + } + }, "next": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", @@ -31295,6 +31428,64 @@ "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==" }, + "ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "requires": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "dependencies": { + "@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==" + }, + "@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "requires": { + "@noble/hashes": "1.3.2" + } + }, + "@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" + }, + "@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "requires": { + "undici-types": "~6.19.2" + } + }, + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, + "undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "requires": {} + } + } + }, "eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -33863,6 +34054,7 @@ "eslint-config-prettier": "^8.10.2", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-unused-imports": "^4.4.1", + "ethers": "^6.12.0", "fast-check": "^3.22.0", "framer-motion": "^12.23.0", "graphql": "^16.8.0", @@ -37745,9 +37937,12 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "optional": true, + "peer": true, "requires": { "tslib": "^2.8.0" } @@ -39815,6 +40010,11 @@ "pkcs7": "^1.0.4" } }, + "aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==" + }, "agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -40771,6 +40971,14 @@ "lodash.clonedeep": "^4.5.0" } }, + "@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "requires": { + "tslib": "^2.8.0" + } + }, "next": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", @@ -42200,6 +42408,64 @@ "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==" }, + "ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "requires": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "dependencies": { + "@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==" + }, + "@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "requires": { + "@noble/hashes": "1.3.2" + } + }, + "@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" + }, + "@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "requires": { + "undici-types": "~6.19.2" + } + }, + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, + "undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "requires": {} + } + } + }, "eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -44783,6 +45049,14 @@ "styled-jsx": "5.1.6" }, "dependencies": { + "@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "requires": { + "tslib": "^2.8.0" + } + }, "postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -49188,6 +49462,14 @@ "styled-jsx": "5.1.6" }, "dependencies": { + "@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "requires": { + "tslib": "^2.8.0" + } + }, "postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", 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 b1edc7c2..2d8bd7ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: dompurify: specifier: ^3.2.4 version: 3.4.7 + ethers: + specifier: ^6.12.0 + version: 6.16.0 framer-motion: specifier: ^12.23.0 version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -82,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) @@ -311,6 +317,9 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} @@ -1770,6 +1779,9 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/curves@1.8.0': resolution: {integrity: sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==} engines: {node: ^14.21.3 || >=16} @@ -1789,6 +1801,10 @@ packages: '@noble/hashes@1.1.5': resolution: {integrity: sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ==} + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@noble/hashes@1.7.0': resolution: {integrity: sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==} engines: {node: ^14.21.3 || >=16} @@ -3025,6 +3041,9 @@ packages: '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -3539,6 +3558,9 @@ packages: aes-decrypter@4.0.2: resolution: {integrity: sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==} + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -4635,6 +4657,10 @@ packages: resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} engines: {node: '>=20'} + ethers@6.16.0: + resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} + engines: {node: '>=14.0.0'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} @@ -5462,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'} @@ -7179,6 +7208,9 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -7258,6 +7290,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -7733,6 +7768,18 @@ packages: utf-8-validate: optional: true + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.18.2: resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} engines: {node: '>=10.0.0'} @@ -7894,6 +7941,8 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@adraffy/ens-normalize@1.10.1': {} + '@adraffy/ens-normalize@1.11.1': {} '@alloc/quick-lru@5.2.0': {} @@ -9420,6 +9469,10 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + '@noble/curves@1.8.0': dependencies: '@noble/hashes': 1.7.0 @@ -9435,10 +9488,11 @@ snapshots: '@noble/curves@1.9.7': dependencies: '@noble/hashes': 1.8.0 - optional: true '@noble/hashes@1.1.5': {} + '@noble/hashes@1.3.2': {} + '@noble/hashes@1.7.0': {} '@noble/hashes@1.8.0': {} @@ -10313,7 +10367,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -11000,6 +11054,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@types/pg@8.20.0': dependencies: '@types/node': 20.19.41 @@ -11748,6 +11806,8 @@ snapshots: global: 4.4.0 pkcs7: 1.0.4 + aes-js@4.0.0-beta.5: {} + agent-base@7.1.4: {} ajv-formats@2.1.1(ajv@8.20.0): @@ -12189,7 +12249,7 @@ snapshots: c32check@2.0.0: dependencies: - '@noble/hashes': 1.1.5 + '@noble/hashes': 1.8.0 base-x: 4.0.1 cac@6.7.14: {} @@ -13162,6 +13222,19 @@ snapshots: eta@4.6.0: {} + ethers@6.16.0: + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + eventemitter3@4.0.7: {} eventemitter3@5.0.1: {} @@ -14203,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 @@ -14698,11 +14773,11 @@ snapshots: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -14713,11 +14788,11 @@ snapshots: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -16156,6 +16231,8 @@ snapshots: tslib@1.14.1: {} + tslib@2.7.0: {} + tslib@2.8.1: {} tsx@4.22.3: @@ -16246,6 +16323,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.19.8: {} + undici-types@6.21.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -16875,6 +16954,8 @@ snapshots: ws@7.5.11: {} + ws@8.17.1: {} + ws@8.18.2: {} ws@8.20.1: {} 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(); + }); + }); +});