16 Commits

Author SHA1 Message Date
Ichitux 792e6dd5b1 Merge pull request 'New icon for PWA. CSS corrections.' (#8) from design_fixes into main
Build & Push Docker Images / test-backend (push) Successful in 35s
Build & Push Docker Images / test-frontend (push) Successful in 30s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 17s
Build & Push Docker Images / build-frontend (push) Successful in 32s
Reviewed-on: #8
2026-07-06 08:11:57 +00:00
Antoni Nuñez Romeu 7550978bc8 New icon for PWA. CSS corrections.
Run Tests on Branches / Backend Tests (push) Successful in 3m32s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s
2026-07-06 10:01:47 +02:00
Ichitux c0c58556e7 Merge pull request 'Logos & scanner changes' (#7) from tsi_uploads into main
Build & Push Docker Images / test-backend (push) Successful in 38s
Build & Push Docker Images / deploy (push) Successful in 20s
Build & Push Docker Images / build-backend (push) Successful in 2m21s
Build & Push Docker Images / build-frontend (push) Successful in 39s
Build & Push Docker Images / test-frontend (push) Successful in 30s
Reviewed-on: #7
2026-07-06 07:20:01 +00:00
Antoni Nuñez Romeu df0b114058 Logos & scanner changes
Run Tests on Branches / Backend Tests (push) Successful in 3m41s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s
2026-07-04 14:46:42 +02:00
Ichitux 3b725df118 Merge pull request 'feat: replace barcode-detector polyfill with local @zxing/browser for cross-browser barcode scanning' (#6) from fixing_barcode into main
Build & Push Docker Images / test-backend (push) Successful in 33s
Build & Push Docker Images / test-frontend (push) Successful in 31s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 22s
Build & Push Docker Images / build-frontend (push) Successful in 54s
Reviewed-on: #6
2026-07-03 10:10:03 +00:00
Antoni Nuñez Romeu 5b080c4134 feat: replace barcode-detector polyfill with local @zxing/browser for cross-browser barcode scanning
Run Tests on Branches / Backend Tests (push) Successful in 3m35s
Run Tests on Branches / Frontend Tests (push) Successful in 3m33s
- Remove barcode-detector polyfill (made remote CDN calls to download zxing-wasm)
- Use @zxing/library@0.22.0 + @zxing/browser@0.2.0 locally
- Support PDF417, Code128, Code39, Code93, QR, EAN, UPC, ITF formats
- Clean special characters from scanned results before CIP validation
- Fix video ref timing issue with waitForVideo loop
2026-07-03 11:58:04 +02:00
Ichitux 0ebd2dc35c Merge pull request 'Fixes on docker compose & notify' (#5) from search_improvements into main
Build & Push Docker Images / test-backend (push) Successful in 32s
Build & Push Docker Images / test-frontend (push) Successful in 29s
Build & Push Docker Images / deploy (push) Successful in 19s
Build & Push Docker Images / build-backend (push) Successful in 19s
Build & Push Docker Images / build-frontend (push) Successful in 31s
Reviewed-on: #5
2026-07-02 15:49:06 +00:00
Antoni Nuñez Romeu 1bc34b1134 Fixes on docker compose & notify
Run Tests on Branches / Backend Tests (push) Successful in 3m28s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s
2026-07-02 17:09:15 +02:00
Ichitux 15e136f3d5 Merge pull request 'Profile_Fixes' (#4) from Profile_Fixes into main
Build & Push Docker Images / test-backend (push) Successful in 36s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 22s
Build & Push Docker Images / build-frontend (push) Successful in 32s
Build & Push Docker Images / test-frontend (push) Successful in 31s
Reviewed-on: #4
2026-07-02 10:52:07 +00:00
Antoni Nuñez Romeu 7606d118e4 Design hotfixes and improvements.
Run Tests on Branches / Backend Tests (push) Successful in 3m32s
Run Tests on Branches / Frontend Tests (push) Successful in 3m29s
2026-07-02 12:40:09 +02:00
Antoni Nuñez Romeu 15c1127b34 Add admin access button in profile menu for admin users
- Add onAdminClick prop to ProfileView component
- Add conditional admin button visible only for is_admin users
- Add handleAdminClick function in App.jsx to navigate to admin view
- Button styled consistently with existing menu items
2026-07-02 11:23:57 +02:00
Antoni Nuñez Romeu 17cd5adaef Add design spec for admin access button in profile menu 2026-07-02 11:20:56 +02:00
Ichitux 4429ec4c7f Merge pull request 'Frontend_Fixes' (#3) from Frontend_Fixes into main
Build & Push Docker Images / test-backend (push) Successful in 33s
Build & Push Docker Images / test-frontend (push) Successful in 28s
Build & Push Docker Images / deploy (push) Successful in 18s
Build & Push Docker Images / build-backend (push) Successful in 18s
Build & Push Docker Images / build-frontend (push) Successful in 31s
Reviewed-on: #3
2026-07-01 15:29:25 +00:00
Antoni Nuñez Romeu 71a34293a9 Views in frontend changed
Run Tests on Branches / Backend Tests (push) Successful in 3m28s
Run Tests on Branches / Frontend Tests (push) Successful in 3m26s
2026-07-01 17:08:15 +02:00
Ichitux 1b9494bce6 Merge pull request 'Testing steps to avoid wrong implementation' (#2) from testing into main
Build & Push Docker Images / test-backend (push) Successful in 32s
Build & Push Docker Images / test-frontend (push) Successful in 30s
Build & Push Docker Images / deploy (push) Successful in 6s
Build & Push Docker Images / build-backend (push) Successful in 19s
Build & Push Docker Images / build-frontend (push) Successful in 18s
Reviewed-on: #2
2026-07-01 14:40:47 +00:00
Ichitux 8578bac776 Merge pull request 'Rebranding app and naming' (#1) from Rebranding into main
Build & Push Docker Images / test-backend (push) Successful in 35s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 2m10s
Build & Push Docker Images / build-frontend (push) Successful in 53s
Build & Push Docker Images / test-frontend (push) Successful in 30s
Reviewed-on: #1
2026-07-01 13:57:19 +00:00
36 changed files with 1616 additions and 588 deletions
+128 -3
View File
@@ -1,11 +1,11 @@
{ {
"name": "farma-finder-backend", "name": "farma-clic-backend",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "farma-finder-backend", "name": "farma-clic-backend",
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -27,11 +27,13 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"multer": "^2.2.0",
"pg": "^8.13.0", "pg": "^8.13.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"redis": "^4.6.0", "redis": "^4.6.0",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"tesseract.js": "^7.0.0",
"web-push": "^3.6.7" "web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
@@ -3727,6 +3729,11 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"node_modules/aproba": { "node_modules/aproba": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
@@ -4020,6 +4027,11 @@
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
} }
}, },
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="
},
"node_modules/bn.js": { "node_modules/bn.js": {
"version": "4.12.3", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
@@ -4151,9 +4163,19 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -4426,6 +4448,20 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/connect-pg-simple": { "node_modules/connect-pg-simple": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
@@ -5682,6 +5718,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/idb-keyval": {
"version": "6.2.6",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.6.tgz",
"integrity": "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA=="
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5870,6 +5911,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7056,6 +7102,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/napi-build-utils": { "node_modules/napi-build-utils": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -7285,6 +7349,14 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -7915,6 +7987,11 @@
"@redis/time-series": "1.1.0" "@redis/time-series": "1.1.0"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -8477,6 +8554,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -8718,6 +8803,28 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw=="
},
"node_modules/test-exclude": { "node_modules/test-exclude": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -8824,6 +8931,11 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/uid-safe": { "node_modules/uid-safe": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
@@ -8964,6 +9076,11 @@
"makeerror": "1.0.12" "makeerror": "1.0.12"
} }
}, },
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="
},
"node_modules/web-push": { "node_modules/web-push": {
"version": "3.6.7", "version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
@@ -9168,6 +9285,14 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"engines": {
"node": "*"
}
} }
} }
} }
+3 -1
View File
@@ -32,15 +32,17 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "^10.0.0",
"connect-sqlite3": "^0.9.16", "connect-sqlite3": "^0.9.16",
"pg": "^8.13.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"multer": "^2.2.0",
"pg": "^8.13.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"redis": "^4.6.0", "redis": "^4.6.0",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"tesseract.js": "^7.0.0",
"web-push": "^3.6.7" "web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
+254 -23
View File
@@ -16,6 +16,8 @@ import rateLimit from 'express-rate-limit';
import webpush from 'web-push'; import webpush from 'web-push';
import pino from 'pino'; import pino from 'pino';
import pinoHttp from 'pino-http'; import pinoHttp from 'pino-http';
import multer from 'multer';
import { createWorker } from 'tesseract.js';
import { searchMedicines, getMedicineDetails } from './cima-service.js'; import { searchMedicines, getMedicineDetails } from './cima-service.js';
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js'; import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
import { fetchPharmaciesExternal } from '../API/index.js'; import { fetchPharmaciesExternal } from '../API/index.js';
@@ -65,7 +67,8 @@ const sessionConfig = {
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: process.env.NODE_ENV === 'production', secure: process.env.COOKIE_SECURE === 'true',
sameSite: 'lax',
httpOnly: true, httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours maxAge: 24 * 60 * 60 * 1000 // 24 hours
} }
@@ -138,6 +141,14 @@ async function userDbRun(sql, params = []) {
return dbRun(sql.replace(/\s+RETURNING\s+\w+\s*$/i, ''), params); return dbRun(sql.replace(/\s+RETURNING\s+\w+\s*$/i, ''), params);
} }
async function userDbAll(sql, params = []) {
if (pgPool) {
const res = await pgPool.query(toPositional(sql), params);
return res.rows;
}
return dbAll(sql, params);
}
// Accept opening_hours as either an object or a JSON string from the client // Accept opening_hours as either an object or a JSON string from the client
// and return a TEXT value (or null) safe to persist. // and return a TEXT value (or null) safe to persist.
function serializeOpeningHours(value) { function serializeOpeningHours(value) {
@@ -225,6 +236,22 @@ async function initDatabase() {
`); `);
} }
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS user_alerts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
type TEXT NOT NULL CHECK (type IN ('reminder', 'availability', 'schedule')),
medicine_nregistro TEXT,
title TEXT,
detail TEXT,
schedule TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
}
await dbRun(` await dbRun(`
CREATE TABLE IF NOT EXISTS push_subscriptions ( CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -254,7 +281,7 @@ async function initDatabase() {
`); `);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`);
if (!pgPool) { if (!pgPool) {
// SQLite-only migrations for existing deployments // SQLite-only migrations for existing deployments
try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {} try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {}
try { await dbRun('ALTER TABLE users ADD COLUMN address TEXT'); } catch {} try { await dbRun('ALTER TABLE users ADD COLUMN address TEXT'); } catch {}
@@ -262,13 +289,32 @@ async function initDatabase() {
try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {} try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {}
} }
// Add user_id to push tables (SQLite — no cross-DB FK) // Add user_id to push tables (SQLite — no cross-DB FK)
try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER'); } catch {} try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER'); } catch {}
try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER'); } catch {} try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER'); } catch {}
console.log('Database initialized successfully'); // Create alerts table for persistent Avisos data
} catch (error) { try {
console.error('Error initializing database:', error); await dbRun(`
CREATE TABLE IF NOT EXISTS user_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK (type IN ('reminder', 'availability', 'schedule')),
medicine_nregistro TEXT,
title TEXT,
detail TEXT,
schedule TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
} catch (e) {
if (!/table already exists/i.test(e.message)) throw e;
}
} catch (err) {
console.error('initDatabase failed:', err);
throw err;
} }
} }
@@ -340,6 +386,75 @@ app.get('/api/medicines/:medicineId', async (req, res) => {
} }
}); });
// ========== AUTHENTICATION MIDDLEWARE ==========
// Middleware to check if user is authenticated
const requireAuth = (req, res, next) => {
if (req.session && req.session.userId) {
return next();
}
return res.status(401).json({ error: 'Authentication required' });
};
// Middleware to check if user is an admin
const requireAdmin = (req, res, next) => {
if (req.session && req.session.userId && req.session.isAdmin) {
return next();
}
if (req.session && req.session.userId) {
return res.status(403).json({ error: 'Admin access required' });
}
return res.status(401).json({ error: 'Authentication required' });
};
// ========== RECENT SEARCHES (session-based) ==========
const MAX_RECENT = 10;
// Get recent searches for the logged-in user
app.get('/api/search/recent', requireAuth, (req, res) => {
res.json(req.session.recentSearches || []);
});
// Save a medicine to recent searches
app.post('/api/search/recent', requireAuth, (req, res) => {
try {
const { medicine } = req.body;
if (!medicine || !medicine.id) {
return res.status(400).json({ error: 'Medicine data required' });
}
if (!req.session.recentSearches) {
req.session.recentSearches = [];
}
// Remove duplicate if exists (by id)
req.session.recentSearches = req.session.recentSearches.filter(
(m) => m.id !== medicine.id
);
// Add to front with timestamp
req.session.recentSearches.unshift({
id: medicine.id,
name: medicine.name,
active_ingredient: medicine.active_ingredient,
dosage: medicine.dosage,
form: medicine.form,
timestamp: Date.now(),
});
// Keep only last MAX_RECENT
if (req.session.recentSearches.length > MAX_RECENT) {
req.session.recentSearches = req.session.recentSearches.slice(0, MAX_RECENT);
}
res.json({ ok: true });
} catch (error) {
console.error('Error saving recent search:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get all pharmacies (for admin/debugging) // Get all pharmacies (for admin/debugging)
app.get('/api/pharmacies', async (req, res) => { app.get('/api/pharmacies', async (req, res) => {
try { try {
@@ -402,26 +517,56 @@ app.get('/api/tsi/:cip/prescriptions', async (req, res) => {
} }
}); });
// ========== AUTHENTICATION MIDDLEWARE ========== // ========== TSI OCR: extract CIP from uploaded photo ==========
// Middleware to check if user is authenticated const upload = multer({
const requireAuth = (req, res, next) => { storage: multer.memoryStorage(),
if (req.session && req.session.userId) { limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB max
return next(); fileFilter: (_req, file, cb) => {
} if (file.mimetype.startsWith('image/')) cb(null, true);
return res.status(401).json({ error: 'Authentication required' }); else cb(new Error('Only image files are accepted'));
}; },
});
// Middleware to check if user is an admin // Extract CIP from OCR text — looks for a long alphanumeric token (16 chars typical for CIP)
const requireAdmin = (req, res, next) => { function extractCip(text) {
if (req.session && req.session.userId && req.session.isAdmin) { const cleaned = text.replace(/\s+/g, '');
return next(); // Try exact 16-char alphanumeric match first
const exact = cleaned.match(/[A-Z0-9]{16}/i);
if (exact) return exact[0].toUpperCase();
// Fallback: 830 alphanumeric chars
const loose = cleaned.match(/[A-Z0-9]{8,30}/i);
if (loose) return loose[0].toUpperCase();
return null;
}
app.post('/api/tsi/ocr', upload.single('photo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No image file provided' });
}
const worker = await createWorker('spa+eng');
const { data } = await worker.recognize(req.file.buffer);
await worker.terminate();
console.log('[OCR] Raw text:', data.text);
const cip = extractCip(data.text);
console.log('[OCR] Extracted CIP:', cip);
if (!cip) {
return res.status(422).json({
error: 'No se pudo detectar un código CIP en la imagen',
ocrText: data.text.trim().slice(0, 500),
});
}
res.json({ cip });
} catch (error) {
console.error('TSI OCR error:', error);
res.status(500).json({ error: 'Error procesando la imagen' });
} }
if (req.session && req.session.userId) { });
return res.status(403).json({ error: 'Admin access required' });
}
return res.status(401).json({ error: 'Authentication required' });
};
// ========== AUTHENTICATION ROUTES ========== // ========== AUTHENTICATION ROUTES ==========
@@ -630,6 +775,92 @@ app.put('/api/users/me', requireAuth, async (req, res) => {
} }
}); });
// ========== USER ALERTS API ==========
// Get all alerts for the current user
app.get('/api/alerts', requireAuth, async (req, res) => {
try {
const userId = req.session.userId;
const rows = await userDbAll(
'SELECT id, type, medicine_nregistro, title, detail, schedule, created_at, updated_at FROM user_alerts WHERE user_id = ? ORDER BY created_at DESC',
[userId]
);
res.json(rows);
} catch (error) {
console.error('Error fetching alerts:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Create a new alert
app.post('/api/alerts', requireAuth, async (req, res) => {
try {
const userId = req.session.userId;
const { type, medicine_nregistro, title, detail, schedule } = req.body || {};
if (!type || !['reminder', 'availability', 'schedule'].includes(type)) {
return res.status(400).json({ error: 'Valid type is required (reminder, availability, schedule)' });
}
const result = await userDbRun(
'INSERT INTO user_alerts (user_id, type, medicine_nregistro, title, detail, schedule) VALUES (?, ?, ?, ?, ?, ?)',
[userId, type, medicine_nregistro || null, title || null, detail || null, schedule || null]
);
const newAlert = await userDbGet('SELECT * FROM user_alerts WHERE id = ?', [result.lastID]);
res.status(201).json(newAlert);
} catch (error) {
console.error('Error creating alert:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update an existing alert
app.put('/api/alerts/:id', requireAuth, async (req, res) => {
try {
const userId = req.session.userId;
const alertId = parseInt(req.params.id);
const { medicine_nregistro, title, detail, schedule } = req.body || {};
const result = await userDbRun(
'UPDATE user_alerts SET medicine_nregistro = ?, title = ?, detail = ?, schedule = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
[medicine_nregistro || null, title || null, detail || null, schedule || null, alertId, userId]
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Alert not found' });
}
const updatedAlert = await userDbGet('SELECT * FROM user_alerts WHERE id = ?', [alertId]);
res.json(updatedAlert);
} catch (error) {
console.error('Error updating alert:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete an alert
app.delete('/api/alerts/:id', requireAuth, async (req, res) => {
try {
const userId = req.session.userId;
const alertId = parseInt(req.params.id);
const result = await userDbRun(
'DELETE FROM user_alerts WHERE id = ? AND user_id = ?',
[alertId, userId]
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Alert not found' });
}
res.status(204).end();
} catch (error) {
console.error('Error deleting alert:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Geocode for any authenticated user (rate-limited). Same impl as /api/admin/geocode. // Geocode for any authenticated user (rate-limited). Same impl as /api/admin/geocode.
app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => { app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => {
const q = (req.query.q || '').trim(); const q = (req.query.q || '').trim();
+10 -8
View File
@@ -7,18 +7,20 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: farmaclic POSTGRES_DB: farmafinder
POSTGRES_USER: farmaclic POSTGRES_USER: farmafinder
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production} POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
backend: backend:
image: git.hacecalor.net/ichitux/farmaclic-backend:latest image: git.hacecalor.net/ichitux/farmafinder-backend:latest
build: build:
context: . context: .
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
restart: unless-stopped restart: unless-stopped
env_file:
- ./backend/.env
ports: ports:
- "3001:3001" - "3001:3001"
environment: environment:
@@ -31,13 +33,13 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD:-} REDIS_PASSWORD: ${REDIS_PASSWORD:-}
DATABASE_PATH: /app/data/database.sqlite DATABASE_PATH: /app/data/database.sqlite
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-} FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
PG_URL: postgresql://farmaclic:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmaclic PG_URL: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder
# OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector # OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector
OTEL_SERVICE_NAME: farmaclic-backend OTEL_SERVICE_NAME: farmafinder-backend
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317 OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
OTEL_TRACES_EXPORTER: otlp OTEL_TRACES_EXPORTER: otlp
OTEL_LOGS_EXPORTER: otlp OTEL_LOGS_EXPORTER: otlp
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmaclic OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmafinder
volumes: volumes:
- backend_data:/app/data - backend_data:/app/data
depends_on: depends_on:
@@ -45,12 +47,12 @@ services:
- postgres - postgres
frontend: frontend:
image: git.hacecalor.net/ichitux/farmaclic-frontend:latest image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
build: build:
context: ./frontend context: ./frontend
args: args:
VITE_FARO_ENDPOINT: ${VITE_FARO_ENDPOINT:-http://localhost:4318} VITE_FARO_ENDPOINT: ${VITE_FARO_ENDPOINT:-http://localhost:4318}
VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmaclic-frontend} VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmafinder-frontend}
VITE_FARO_ENV: ${VITE_FARO_ENV:-production} VITE_FARO_ENV: ${VITE_FARO_ENV:-production}
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0} VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
restart: unless-stopped restart: unless-stopped
@@ -0,0 +1,73 @@
# Diseño: Acceso al Panel de Administración
## Resumen
Añadir un botón de acceso al panel de administración en la página de perfil (pestaña "Usuario") que solo sea visible para usuarios con permisos de administrador.
## Objetivo
Proporcionar una forma intuitiva y consistente para que los usuarios administradores accedan al panel de administración desde la interfaz principal.
## Requisitos
1. El botón solo debe aparecer cuando el usuario tiene `is_admin: true`
2. El botón debe seguir el mismo estilo que los otros elementos del menú del perfil
3. Al hacer clic, debe navegar a la vista de administración existente
4. No debe afectar la experiencia de usuarios no administradores
## Diseño Detallado
### Ubicación
- **Página:** ProfileView (pestaña "Usuario" en BottomNav)
- **Sección:** Dentro de `.profile-menu`, después de "Configuración de Texto"
- **Posición:** Antes del botón "Cerrar Sesión"
### Componente
- **Tipo:** Botón con ícono, texto y chevron (igual que otros items del menú)
- **Ícono:** Engranaje (⚙️) con fondo de color primario
- **Texto:** "Panel de Administración"
- **Estilo:** Idéntico a `.profile-menu-item` existente
### Comportamiento
1. **Renderizado condicional:**
```jsx
{currentUser?.is_admin && (
<button className="profile-menu-item" onClick={onAdminClick}>
<div className="menu-item-icon menu-item-icon--primary">
<svg>...</svg>
</div>
<span className="menu-item-label">Panel de Administración</span>
<svg className="menu-item-chevron">...</svg>
</button>
)}
```
2. **Navegación:**
- Recibir función `onAdminClick` como prop
- En `App.jsx`, implementar `handleAdminClick` que cambie `screen` a `'admin'`
### Cambios en Archivos
#### 1. `frontend/src/views/ProfileView.jsx`
- Añadir prop `onAdminClick`
- Renderizar botón condicionalmente basado en `currentUser?.is_admin`
#### 2. `frontend/src/App.jsx`
- Añadir función `handleAdminClick` que establezca `screen` en `'admin'`
- Pasar `onAdminClick={handleAdminClick}` a `ProfileView`
### Estilos
No se requieren cambios en CSS - se reutilizan las clases existentes:
- `.profile-menu-item`
- `.menu-item-icon--primary`
- `.menu-item-label`
- `.menu-item-chevron`
## Criterios de Aceptación
1. ✅ Botón visible solo para usuarios admin
2. ✅ Botón no visible para usuarios normales
3. ✅ Estilo consistente con otros elementos del menú
4. ✅ Al hacer clic, navega a la vista de administración
5. ✅ No se muestra en dispositivos móviles si el usuario no es admin
## Impacto
- **Archivos modificados:** 2 (`ProfileView.jsx`, `App.jsx`)
- **Complejidad:** Baja
- **Riesgo:** Mínimo - solo añade un elemento condicional
+2 -2
View File
@@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#00450d" /> <meta name="theme-color" content="#7fbf8f" />
<title>FarmaClic</title> <title>FarmaClic</title>
<link rel="icon" href="/favicon.png" type="image/png" /> <link rel="icon" href="/favicon.png" type="image/png" />
<link rel="icon" href="/favicon.ico" sizes="32x32" /> <link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="apple-touch-icon" href="/farmaclic_logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
+43 -53
View File
@@ -1,11 +1,11 @@
{ {
"name": "farma-finder-frontend", "name": "farma-clic-frontend",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "farma-finder-frontend", "name": "farma-clic-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@capacitor-mlkit/barcode-scanning": "^8.1.0", "@capacitor-mlkit/barcode-scanning": "^8.1.0",
@@ -17,7 +17,8 @@
"@grafana/faro-transport-otlp-http": "^1.7.0", "@grafana/faro-transport-otlp-http": "^1.7.0",
"@grafana/faro-web-sdk": "^1.7.0", "@grafana/faro-web-sdk": "^1.7.0",
"@grafana/faro-web-tracing": "^1.7.0", "@grafana/faro-web-tracing": "^1.7.0",
"barcode-detector": "^3.2.0", "@zxing/browser": "^0.2.0",
"@zxing/library": "^0.22.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -3467,11 +3468,6 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3696,6 +3692,37 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@zxing/browser": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.0.tgz",
"integrity": "sha512-+ORhrLva0vm6ck74NDCmvYNW3XLoAG81Mu90qfcssN1PBKJjQadxZGeMCcIk+BdJbD/zEAjjHDXOwEK1QCmRtw==",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.22.0"
}
},
"node_modules/@zxing/library": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz",
"integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==",
"dependencies": {
"ts-custom-error": "^3.3.1"
},
"engines": {
"node": ">= 24.0.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"optional": true
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3943,14 +3970,6 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/barcode-detector": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz",
"integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==",
"dependencies": {
"zxing-wasm": "3.1.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.11", "version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
@@ -7490,17 +7509,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/temp-dir": { "node_modules/temp-dir": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -7635,6 +7643,14 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -8564,32 +8580,6 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zxing-wasm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz",
"integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==",
"dependencies": {
"@types/emscripten": "^1.41.5",
"type-fest": "^5.7.0"
},
"peerDependencies": {
"@types/emscripten": ">=1.39.6"
}
},
"node_modules/zxing-wasm/node_modules/type-fest": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
"integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
} }
} }
} }
+2 -1
View File
@@ -18,7 +18,8 @@
"@grafana/faro-transport-otlp-http": "^1.7.0", "@grafana/faro-transport-otlp-http": "^1.7.0",
"@grafana/faro-web-sdk": "^1.7.0", "@grafana/faro-web-sdk": "^1.7.0",
"@grafana/faro-web-tracing": "^1.7.0", "@grafana/faro-web-tracing": "^1.7.0",
"barcode-detector": "^3.2.0", "@zxing/browser": "^0.2.0",
"@zxing/library": "^0.22.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+128 -8
View File
@@ -9,21 +9,141 @@
.app-content { .app-content {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior-y: contain;
} }
/* Mobile-specific styles */
.mobile-view {
width: 100%;
padding: 0;
margin: 0;
}
/* Desktop-specific styles */
.desktop-view {
max-width: 100%;
padding: 0 var(--margin-main);
}
/* Mobile-specific view adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.app-content { .app-logo-wrapper {
padding-bottom: calc(6rem + env(safe-area-inset-bottom)); flex-direction: column;
}
.home-card--scan {
min-height: 4.25rem;
padding: 0.75rem 1.25rem;
}
.home-card-icon {
width: 2.75rem;
height: 2.75rem;
} }
} }
@keyframes fadeInUp { /* Tablet-specific styles */
from { opacity: 0; transform: translateY(14px); } @media (min-width: 769px) and (max-width: 1024px) {
to { opacity: 1; transform: translateY(0); } .home-card {
min-height: 4rem;
padding: 0.625rem 1rem;
}
} }
@keyframes spin { /* Desktop-specific styles */
to { transform: rotate(360deg); } @media (min-width: 1025px) {
.app-content {
padding: 0 5rem;
}
.home-card {
min-height: 3.5rem;
padding: 0.5rem 0.875rem;
}
}
body {
min-height: 100vh;
font-size: calc(16px + 0.5vmin);
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
color: var(--on-surface);
background-color: var(--surface-container-lowest);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Touch target sizing for mobile */
@media (max-width: 768px) {
a, button {
min-height: 4rem;
padding: 1rem;
}
}
/* Card styling improvements */
.home-card {
width: 100%;
max-width: 28rem;
min-height: fit-content;
transition: transform 0.15s, box-shadow 0.15s;
}
.home-card:hover {
transform: translateY(-3px);
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.1);
}
/* Fluid typography */
.home-desc {
font-size: clamp(1rem, 100vw, 1.5rem);
max-width: 100%;
}
.home-card-label {
font-size: clamp(1rem, 100vw, 1.25rem);
}
/* Global button styles using pastel variables */
button,
.btn {
appearance: none;
background: var(--primary);
color: var(--on-primary);
border: none;
padding: 0.6rem 1rem;
border-radius: var(--radius);
box-shadow: var(--primary-shadow);
transition: background-color 160ms ease, transform 120ms ease, box-shadow 160ms ease;
cursor: pointer;
}
button:hover,
.btn:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
button:focus,
.btn:focus {
outline: none;
box-shadow: 0 0 0 6px var(--primary-ring);
}
/* Outline / subtle buttons */
.btn--outline {
background: transparent;
color: var(--on-secondary);
border: 1px solid var(--secondary-container);
}
.btn--secondary {
background: var(--secondary);
color: var(--on-secondary);
}
.btn--ghost {
background: transparent;
color: var(--on-surface);
} }
+28 -22
View File
@@ -8,7 +8,6 @@ import ProfileView from './views/ProfileView';
import AdminView from './views/AdminView'; import AdminView from './views/AdminView';
import LoginModal from './components/LoginModal'; import LoginModal from './components/LoginModal';
import SavedNotifications from './components/SavedNotifications'; import SavedNotifications from './components/SavedNotifications';
import TopBar from './components/TopBar';
import BottomNav from './components/BottomNav'; import BottomNav from './components/BottomNav';
function App() { function App() {
@@ -17,13 +16,33 @@ function App() {
const [authChecked, setAuthChecked] = useState(false); const [authChecked, setAuthChecked] = useState(false);
const [showLogin, setShowLogin] = useState(false); const [showLogin, setShowLogin] = useState(false);
const [showSaved, setShowSaved] = useState(false); const [showSaved, setShowSaved] = useState(false);
const [screenSize, setScreenSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
// Device detection
const isMobile = screenSize.width <= 768;
const isTablet = screenSize.width > 768 && screenSize.width <= 1024;
const isDesktop = screenSize.width > 1024;
useEffect(() => { useEffect(() => {
// Set initial screen size
const handleResize = () => {
setScreenSize({
width: window.innerWidth,
height: window.innerHeight
});
};
fetch('/api/auth/check') fetch('/api/auth/check')
.then(r => r.json()) .then(r => r.json())
.then(data => { if (data.authenticated) setCurrentUser(data.user); }) .then(data => { if (data.authenticated) setCurrentUser(data.user); })
.catch(() => {}) .catch(() => {})
.finally(() => setAuthChecked(true)); .finally(() => setAuthChecked(true));
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
function handleLogin(user) { function handleLogin(user) {
@@ -41,6 +60,10 @@ function App() {
setCurrentUser(prev => ({ ...prev, ...updated })); setCurrentUser(prev => ({ ...prev, ...updated }));
} }
function handleAdminClick() {
setScreen('admin');
}
const isLoggedIn = Boolean(currentUser); const isLoggedIn = Boolean(currentUser);
function handleNavChange(tab) { function handleNavChange(tab) {
@@ -59,13 +82,8 @@ function App() {
} }
} }
function handleBack() {
setScreen('home');
}
let activeView; let activeView;
let topBarTitle = 'FarmaClic'; let deviceClass = isMobile ? 'mobile-view' : 'desktop-view';
let showBack = false;
switch (screen) { switch (screen) {
case 'profile': case 'profile':
@@ -75,13 +93,12 @@ function App() {
onProfileSaved={handleProfileSaved} onProfileSaved={handleProfileSaved}
onShowSaved={() => setShowSaved(true)} onShowSaved={() => setShowSaved(true)}
onLogout={handleLogout} onLogout={handleLogout}
onAdminClick={handleAdminClick}
/> />
); );
showBack = true;
break; break;
case 'alerts': case 'alerts':
activeView = <AlertsView />; activeView = <AlertsView />;
showBack = true;
break; break;
case 'search': case 'search':
activeView = ( activeView = (
@@ -90,7 +107,6 @@ function App() {
onLoginRequest={() => setShowLogin(true)} onLoginRequest={() => setShowLogin(true)}
/> />
); );
showBack = true;
break; break;
case 'scan': case 'scan':
activeView = ( activeView = (
@@ -101,12 +117,9 @@ function App() {
}} }}
/> />
); );
showBack = false;
break; break;
case 'admin': case 'admin':
activeView = <AdminView />; activeView = <AdminView />;
topBarTitle = 'Admin';
showBack = true;
break; break;
default: default:
activeView = ( activeView = (
@@ -117,18 +130,11 @@ function App() {
onSearchClick={() => setScreen('search')} onSearchClick={() => setScreen('search')}
/> />
); );
showBack = false;
break; break;
} }
return ( return (
<div className="app"> <div className={`app ${deviceClass}`}>
<TopBar
title={topBarTitle}
showBack={showBack}
onBack={handleBack}
/>
<div className="app-content"> <div className="app-content">
{activeView} {activeView}
</div> </div>
@@ -136,7 +142,7 @@ function App() {
<BottomNav <BottomNav
activeTab={screen} activeTab={screen}
onChange={handleNavChange} onChange={handleNavChange}
isLoggedIn={isLoggedIn} isLoggedIn={Boolean(currentUser)}
badgeCount={currentUser ? 2 : 0} badgeCount={currentUser ? 2 : 0}
/> />
+136 -116
View File
@@ -1,123 +1,143 @@
.bottom-nav { .bottom-nav {
display: none; display: flex;
justify-content: space-around;
align-items: flex-end;
width: 100%;
max-width: 48rem;
z-index: 50;
padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
background: var(--surface-container-lowest);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
height: 5.5rem;
flex-shrink: 0;
margin: 0 auto;
} }
@media (max-width: 768px) { /* Desktop navigation - full width */
@media (min-width: 1025px) {
.bottom-nav { .bottom-nav {
display: flex; max-width: 100%;
justify-content: space-around; padding: 0.25rem 2rem calc(0.5rem + env(safe-area-inset-bottom));
align-items: flex-end;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
background: var(--surface-container-lowest);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-radius: var(--radius-lg) var(--radius-lg) 0 0;
height: 5.5rem;
}
.bottom-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 0.15rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--on-surface-variant);
font: inherit;
-webkit-tap-highlight-color: transparent;
transition: color 0.15s;
position: relative;
min-width: 3.5rem;
}
.bottom-nav-item.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nav-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--radius-full);
color: inherit;
position: relative;
}
.nav-fab {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: var(--radius-full);
background: var(--tertiary-container);
color: var(--on-tertiary);
box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35);
margin-top: -1.5rem;
transition: transform 0.15s;
}
.nav-fab:active {
transform: scale(0.92);
}
.nav-badge {
position: absolute;
top: 0;
right: -2px;
width: 1.125rem;
height: 1.125rem;
background: var(--error);
color: var(--on-error);
font-size: 0.625rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
border: 2px solid var(--surface-container-lowest);
line-height: 1;
}
.nav-label {
font-size: 0.7rem;
font-weight: 500;
line-height: 1;
color: var(--on-surface-variant);
}
.nav-label--active {
color: var(--primary);
font-weight: 700;
}
.nav-indicator {
width: 0.375rem;
height: 0.375rem;
background: var(--primary);
border-radius: var(--radius-full);
margin-top: 0.125rem;
}
.bottom-nav-item.active .nav-icon-wrap {
color: var(--primary);
}
.bottom-nav-item.active .nav-icon-wrap svg {
stroke-width: 2.5;
}
.nav-elevated.active .nav-label {
color: var(--tertiary);
} }
} }
.bottom-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 0.15rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--on-surface-variant);
font: inherit;
-webkit-tap-highlight-color: transparent;
transition: color 0.15s;
position: relative;
min-width: 3.5rem;
}
.bottom-nav-item.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nav-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--radius-full);
color: inherit;
position: relative;
}
.nav-fab {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: var(--radius-full);
/* Stronger, brighter blue to make scan button easy to find */
background: #2b5bb5;
color: #ffffff;
box-shadow: 0 8px 26px rgba(43, 91, 181, 0.32);
margin-top: -1.5rem;
transition: transform 0.12s, box-shadow 160ms ease;
}
.nav-fab:active {
transform: scale(0.92);
}
.nav-fab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 36px rgba(43, 91, 181, 0.36);
}
.nav-fab:focus {
outline: none;
box-shadow: 0 0 0 6px rgba(43,91,181,0.12), 0 8px 26px rgba(43, 91, 181, 0.32);
}
.nav-badge {
position: absolute;
top: 0;
right: -2px;
width: 1.125rem;
height: 1.125rem;
background: var(--error);
color: var(--on-error);
font-size: 0.625rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
border: 2px solid var(--surface-container-lowest);
line-height: 1;
}
.nav-label {
font-size: 0.7rem;
font-weight: 500;
line-height: 1;
color: var(--on-surface-variant);
}
.nav-label--active {
color: var(--primary);
font-weight: 700;
}
.nav-indicator {
width: 0.375rem;
height: 0.375rem;
background: var(--primary);
border-radius: var(--radius-full);
margin-top: 0.125rem;
}
.bottom-nav-item.active .nav-icon-wrap {
color: var(--primary);
}
.bottom-nav-item.active .nav-icon-wrap svg {
stroke-width: 2.5;
}
.bottom-nav-item:hover {
color: var(--primary);
background: transparent;
font-weight: 700;
}
.nav-elevated.active .nav-label {
color: var(--tertiary);
}
@@ -123,16 +123,16 @@
gap: 0.25rem; gap: 0.25rem;
padding: 0.2rem 0.55rem; padding: 0.2rem 0.55rem;
border-radius: 999px; border-radius: 999px;
background: rgba(37, 99, 235, 0.08); background: var(--secondary-container, #dbe7ff);
color: var(--primary, #2563eb); color: var(--on-secondary, #06204a);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
width: fit-content; width: fit-content;
} }
.saved-notifications-chip--pharmacy { .saved-notifications-chip--pharmacy {
background: rgba(4, 120, 87, 0.1); background: var(--primary-container, #cfead0);
color: var(--accent, #047857); color: var(--on-primary, #09310a);
} }
.saved-notifications-address { .saved-notifications-address {
-63
View File
@@ -1,63 +0,0 @@
.top-bar {
display: none;
}
@media (max-width: 768px) {
.top-bar {
display: block;
position: sticky;
top: 0;
z-index: 40;
background: var(--surface);
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.topbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--touch-target-min);
padding: 0 var(--margin-main);
max-width: 48rem;
margin: 0 auto;
}
.topbar-back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-full);
border: none;
background: transparent;
color: var(--primary);
cursor: pointer;
transition: background 0.15s;
}
.topbar-back:hover {
background: var(--surface-container-high);
}
.topbar-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--primary);
letter-spacing: -0.01em;
}
.topbar-logo-img {
height: 2rem;
width: auto;
}
.topbar-spacer {
width: 2.5rem;
}
.topbar-right {
display: flex;
align-items: center;
}
}
-28
View File
@@ -1,28 +0,0 @@
import './TopBar.css';
function TopBar({ title, showBack, onBack, rightAction }) {
return (
<header className="top-bar">
<div className="topbar-inner">
{showBack ? (
<button className="topbar-back" onClick={onBack} aria-label="Volver">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5" />
<polyline points="12 19 5 12 12 5" />
</svg>
</button>
) : (
<div className="topbar-spacer" />
)}
{showBack ? (
<h1 className="topbar-title">{title}</h1>
) : (
<img src="/logo.png" alt="FarmaClic" className="topbar-logo-img" />
)}
<div className="topbar-right">{rightAction || <div className="topbar-spacer" />}</div>
</div>
</header>
);
}
export default TopBar;
+33 -24
View File
@@ -1,35 +1,36 @@
:root { :root {
--primary: #00450d; /* Pastel, softer palette */
--on-primary: #ffffff; --primary: #7fbf8f; /* soft green */
--primary-container: #1b5e20; --on-primary: #09310a; /* dark accent for contrast */
--on-primary-container: #90d689; --primary-container: #cfead0;
--secondary: #2b5bb5; --on-primary-container: #0d2b12;
--on-secondary: #ffffff; --secondary: #a3b8ff; /* soft blue */
--secondary-container: #759efd; --on-secondary: #06204a;
--on-secondary-container: #00337c; --secondary-container: #dbe7ff;
--tertiary: #4600ad; --on-secondary-container: #0b2a66;
--on-tertiary: #ffffff; --tertiary: #c9b3ff; /* soft lavender */
--tertiary-container: #6100e8; --on-tertiary: #241342;
--on-tertiary-container: #cebbff; --tertiary-container: #efe7ff;
--surface: #f8fafb; --on-tertiary-container: #3b2a5a;
--on-surface: #191c1d; --surface: #fbfbfb;
--on-surface: #111417;
--on-surface-variant: #41493e; --on-surface-variant: #41493e;
--surface-container-lowest: #ffffff; --surface-container-lowest: #ffffff;
--surface-container-low: #f2f4f5; --surface-container-low: #f2f4f5;
--surface-container: #eceeef; --surface-container: #eceeef;
--surface-container-high: #e6e8e9; --surface-container-high: #e6e8e9;
--surface-container-highest: #e1e3e4; --surface-container-highest: #e1e3e4;
--error: #ba1a1a; --error: #b91c1c;
--on-error: #ffffff; --on-error: #ffffff;
--error-container: #ffdad6; --error-container: #feecec;
--on-error-container: #93000a; --on-error-container: #610909;
--outline: #717a6d; --outline: #717a6d;
--outline-variant: #c0c9bb; --outline-variant: #c0c9bb;
--on-background: #191c1d; --on-background: #191c1d;
--inverse-surface: #2e3132; --inverse-surface: #2e3132;
--inverse-on-surface: #eff1f2; --inverse-on-surface: #eff1f2;
--inverse-primary: #91d78a; --inverse-primary: #91d78a;
--surface-tint: #2a6b2c; --surface-tint: #8ccb8e;
--surface-dim: #d8dadb; --surface-dim: #d8dadb;
--surface-bright: #f8fafb; --surface-bright: #f8fafb;
--surface-variant: #e1e3e4; --surface-variant: #e1e3e4;
@@ -64,12 +65,12 @@
--glass-border: var(--outline-variant); --glass-border: var(--outline-variant);
--glass-shadow: var(--shadow-soft); --glass-shadow: var(--shadow-soft);
--accent: var(--primary); --accent: var(--primary);
--accent-warm: #b45309; --accent-warm: #f5a97a;
--primary-hover: #1a6b2e; --primary-hover: #6fb47f; /* slightly darker than primary */
--primary-ring: rgba(0, 69, 13, 0.22); --primary-ring: rgba(127, 191, 143, 0.22);
--primary-shadow: rgba(0, 69, 13, 0.18); --primary-shadow: rgba(127, 191, 143, 0.12);
--primary-light: var(--primary-fixed); --primary-light: #eaf7ec;
--primary-faint: rgba(0, 69, 13, 0.08); --primary-faint: rgba(127, 191, 143, 0.06);
} }
* { * {
@@ -108,6 +109,14 @@ body::before {
z-index: -1; z-index: -1;
} }
body::after {
content: "";
position: fixed;
inset: 0;
background-color: rgba(255, 252, 245, 0.48);
z-index: -1;
}
#root { #root {
height: 100dvh; height: 100dvh;
overflow: hidden; overflow: hidden;
+1 -1
View File
@@ -13,7 +13,7 @@ export async function initNativeShell() {
try { try {
const { StatusBar, Style } = await import('@capacitor/status-bar'); const { StatusBar, Style } = await import('@capacitor/status-bar');
await StatusBar.setBackgroundColor({ color: '#0f766e' }).catch(() => {}); await StatusBar.setBackgroundColor({ color: '#27633a' }).catch(() => {});
await StatusBar.setStyle({ style: Style.Light }).catch(() => {}); await StatusBar.setStyle({ style: Style.Light }).catch(() => {});
} catch { } catch {
/* plugin not present — fine on web */ /* plugin not present — fine on web */
+3 -1
View File
@@ -35,7 +35,7 @@ export function pushSupported() {
} }
export async function getVapidPublicKey() { export async function getVapidPublicKey() {
const res = await fetch('/api/notifications/vapid-public-key'); const res = await fetch('/api/notifications/vapid-public-key', { credentials: 'include' });
if (!res.ok) { if (!res.ok) {
const message = res.status === 503 const message = res.status === 503
? 'Las notificaciones push no están configuradas en el servidor' ? 'Las notificaciones push no están configuradas en el servidor'
@@ -88,6 +88,7 @@ export async function subscribeToPush(medicineNregistro, medicineName, pharmacyI
const subscription = await getOrCreateSubscription(); const subscription = await getOrCreateSubscription();
const res = await fetch('/api/notifications/subscribe', { const res = await fetch('/api/notifications/subscribe', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
medicine_nregistro: medicineNregistro, medicine_nregistro: medicineNregistro,
@@ -115,6 +116,7 @@ export async function unsubscribeFromPush(medicineNregistro, pharmacyId = null)
if (endpoint) { if (endpoint) {
await fetch('/api/notifications/unsubscribe', { await fetch('/api/notifications/unsubscribe', {
method: 'DELETE', method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
medicine_nregistro: medicineNregistro, medicine_nregistro: medicineNregistro,
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { subscribeToPush } from './notifications.js';
describe('subscribeToPush', () => {
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
it('sends the subscription request with credentials so the session is preserved', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ publicKey: 'test-public-key' }) })
.mockResolvedValueOnce({ ok: true, text: async () => '' });
vi.stubGlobal('fetch', fetchMock);
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
});
Object.defineProperty(window, 'PushManager', {
configurable: true,
value: class PushManager {},
});
Object.defineProperty(window, 'Notification', {
configurable: true,
value: { permission: 'granted' },
});
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
ready: Promise.resolve({
pushManager: {
getSubscription: vi.fn().mockResolvedValue(null),
subscribe: vi.fn().mockResolvedValue({ endpoint: 'https://example.test/push' }),
},
}),
},
});
await subscribeToPush('123456', 'Paracetamol');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][1]).toMatchObject({
method: 'POST',
credentials: 'include',
});
});
});
-41
View File
@@ -44,7 +44,6 @@
} }
.alert-card--primary { border-color: rgba(0, 69, 13, 0.1); } .alert-card--primary { border-color: rgba(0, 69, 13, 0.1); }
.alert-card--secondary { border-color: rgba(43, 91, 181, 0.1); }
.alert-card--tertiary { border-color: rgba(70, 0, 173, 0.1); } .alert-card--tertiary { border-color: rgba(70, 0, 173, 0.1); }
.alert-card-top { .alert-card-top {
@@ -68,11 +67,6 @@
color: var(--on-primary-container); color: var(--on-primary-container);
} }
.alert-icon--secondary {
background: var(--secondary-container);
color: var(--on-secondary-container);
}
.alert-icon--tertiary { .alert-icon--tertiary {
background: var(--tertiary-container); background: var(--tertiary-container);
color: var(--on-tertiary); color: var(--on-tertiary);
@@ -106,11 +100,6 @@
color: var(--on-error-container); color: var(--on-error-container);
} }
.alert-badge--secondary {
background: var(--secondary-fixed);
color: var(--on-secondary-fixed-variant);
}
.alert-badge--tertiary { .alert-badge--tertiary {
background: var(--tertiary-fixed); background: var(--tertiary-fixed);
color: var(--on-tertiary-fixed-variant); color: var(--on-tertiary-fixed-variant);
@@ -122,36 +111,6 @@
align-items: center; align-items: center;
} }
.alert-btn {
flex: 1;
height: var(--touch-target-min);
border: none;
border-radius: var(--radius-md);
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: opacity 0.15s;
}
.alert-btn:active {
transform: scale(0.97);
}
.alert-btn--primary {
background: var(--primary);
color: var(--on-primary);
}
.alert-btn--secondary {
background: var(--secondary);
color: var(--on-secondary);
}
.alert-btn--tertiary {
background: var(--tertiary-container);
color: var(--on-tertiary);
}
.alert-delete { .alert-delete {
width: var(--touch-target-min); width: var(--touch-target-min);
height: var(--touch-target-min); height: var(--touch-target-min);
+138 -69
View File
@@ -1,36 +1,6 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import './AlertsView.css'; import './AlertsView.css';
const alerts = [
{
id: 1,
type: 'reminder',
title: 'Recordatorio: Comprar Paracetamol',
detail: 'Quedan 2 cajas',
color: 'primary',
icon: 'bell',
actions: [{ label: 'Ver Detalles', variant: 'secondary' }],
},
{
id: 2,
type: 'availability',
title: 'Disponibilidad: Aspirina',
detail: 'Ya disponible en Farmacia Sol',
color: 'secondary',
icon: 'store',
actions: [{ label: 'Ir a Farmacia', variant: 'primary' }],
},
{
id: 3,
type: 'dose',
title: 'Próxima Toma',
detail: '12:00 PM',
color: 'tertiary',
icon: 'schedule',
actions: [{ label: 'Confirmar Toma', variant: 'tertiary' }],
},
];
const iconMap = { const iconMap = {
bell: ( bell: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor"> <svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
@@ -42,14 +12,84 @@ const iconMap = {
<path d="M18.36 9l.6 3H5.04l.6-3h12.72M20 4H4v2h16V4zm0 3H4l-1 5v2h1v6h10v-6h4v6h2v-6h1v-2l-1-5zM6 18v-4h6v4H6z" /> <path d="M18.36 9l.6 3H5.04l.6-3h12.72M20 4H4v2h16V4zm0 3H4l-1 5v2h1v6h10v-6h4v6h2v-6h1v-2l-1-5zM6 18v-4h6v4H6z" />
</svg> </svg>
), ),
schedule: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
</svg>
),
}; };
function AlertsView({ onBack }) { function AlertsView() {
const [availability, setAvailability] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchAvailabilityNotifications = async () => {
try {
setLoading(true);
setError(null);
const notifsRes = await fetch('/api/notifications/mine', { credentials: 'include' });
if (notifsRes.status === 401) {
setError('Por favor, inicia sesión para ver tus avisos.');
setLoading(false);
return;
}
let notifsData = { global: [], pharmacy: [] };
if (notifsRes.ok) {
notifsData = await notifsRes.json();
}
const mergedAvailability = [
...(notifsData.global || []).map(item => ({ ...item, scope: 'global' })),
...(notifsData.pharmacy || []).map(item => ({ ...item, scope: 'pharmacy' })),
].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
setAvailability(mergedAvailability);
} catch (err) {
console.error('Error fetching availability notifications:', err);
setError('No se pudieron cargar los avisos.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAvailabilityNotifications();
}, []);
const handleDeleteAvailability = async (item) => {
try {
const res = await fetch('/api/notifications/mine', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ scope: item.scope, id: item.id }),
});
if (res.ok || res.status === 204) {
setAvailability(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id)));
// Also update local storage if possible to reflect that we unsubscribed
try {
const STORAGE_KEY = 'farmaclic.subscriptions.v1';
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const keyToSearch = item.scope === 'pharmacy'
? `${item.medicine_nregistro}:${item.pharmacy_id}`
: item.medicine_nregistro;
const filtered = parsed.filter(k => k !== keyToSearch);
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
}
}
} catch (e) {
console.warn('Could not update localStorage subscriptions:', e);
}
} else {
throw new Error('Error deleting availability alert');
}
} catch (err) {
console.error(err);
setError('No se pudo eliminar la alerta de disponibilidad.');
}
};
return ( return (
<div className="alerts-view"> <div className="alerts-view">
<main className="alerts-main"> <main className="alerts-main">
@@ -58,39 +98,68 @@ function AlertsView({ onBack }) {
<p className="alerts-subtitle">Mantente al día con tus medicamentos.</p> <p className="alerts-subtitle">Mantente al día con tus medicamentos.</p>
</div> </div>
<div className="alerts-list"> {loading && <p className="alerts-status" style={{ textAlign: 'center', padding: '2rem' }}>Cargando avisos...</p>}
{alerts.map((alert) => ( {!loading && error && <p className="alerts-error" style={{ color: 'var(--error)', textAlign: 'center', padding: '2rem' }}>{error}</p>}
<div key={alert.id} className={`alert-card alert-card--${alert.color}`}>
<div className="alert-card-top"> {!loading && !error && (
<div className={`alert-icon alert-icon--${alert.color}`}> <>
{iconMap[alert.icon]} {/* Availability Section */}
</div> <section className="alerts-section">
<div className="alert-info"> <h3 className="alerts-section-title" style={{ marginBottom: '1rem' }}>Disponibilidad</h3>
<h3 className="alert-title">{alert.title}</h3> <div className="alerts-list">
<span className={`alert-badge alert-badge--${alert.color}`}> {availability.length === 0 ? (
{alert.detail} <p className="alerts-empty" style={{ color: 'var(--on-surface-variant)', fontStyle: 'italic' }}>No tienes suscripciones de disponibilidad.</p>
</span> ) : (
</div> availability.map((alert) => {
const cardColor = alert.scope === 'pharmacy' ? 'primary' : 'tertiary';
const cardIcon = alert.scope === 'pharmacy' ? 'store' : 'bell';
const key = `${alert.scope}:${alert.id}`;
return (
<div key={key} className={`alert-card alert-card--${cardColor}`}>
<div className="alert-card-top">
<div className={`alert-icon alert-icon--${cardColor}`}>
{iconMap[cardIcon]}
</div>
<div className="alert-info">
<h3 className="alert-title">{alert.medicine_name || alert.medicine_nregistro}</h3>
{alert.scope === 'pharmacy' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', marginTop: '0.25rem' }}>
<span className={`alert-badge alert-badge--${cardColor}`} style={{ width: 'fit-content' }}>
🏥 {alert.pharmacy_name || `Farmacia #${alert.pharmacy_id}`}
</span>
{alert.pharmacy_address && (
<p className="alert-address-text" style={{ fontSize: '0.9rem', color: 'var(--on-surface-variant)', margin: 0 }}>
📍 {alert.pharmacy_address}
</p>
)}
</div>
) : (
<span className={`alert-badge alert-badge--${cardColor}`} style={{ width: 'fit-content', marginTop: '0.25rem' }}>
🔔 Notificarme cuando esté disponible
</span>
)}
</div>
</div>
<div className="alert-actions" style={{ justifyContent: 'flex-end' }}>
<button
className="alert-delete"
aria-label="Eliminar"
onClick={() => handleDeleteAvailability(alert)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
);
})
)}
</div> </div>
<div className="alert-actions"> </section>
{alert.actions.map((action, i) => ( </>
<button )}
key={i}
className={`alert-btn alert-btn--${action.variant}`}
>
{action.label}
</button>
))}
<button className="alert-delete" aria-label="Eliminar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
))}
</div>
</main> </main>
</div> </div>
); );
+124 -15
View File
@@ -3,13 +3,113 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; min-height: 100%;
overflow: hidden; max-width: 100%;
padding: 1rem var(--margin-main); width: 100%;
padding: 1rem;
gap: 1.5rem; gap: 1.5rem;
animation: fadeInUp 0.6s ease-out; animation: fadeInUp 0.6s ease-out;
} }
/* Mobile optimizations */
@media (max-width: 768px) {
.home-view {
padding: 0.75rem;
gap: 1rem;
}
.home-logo-wrapper {
flex-direction: column;
align-items: center;
}
.home-logo {
width: min(12rem, 80vw);
}
.home-desc {
font-size: clamp(0.9rem, 5vw, 1.125rem);
max-width: 90%;
text-align: center;
}
.home-cards {
width: 100%;
max-width: 100%;
gap: 1rem;
}
.home-card {
min-height: 4rem;
padding: 0.75rem 1rem;
gap: 0.75rem;
flex-direction: row;
}
.home-card-icon {
width: 2.5rem;
height: 2.5rem;
}
.home-card-label {
font-size: 1rem;
}
}
/* Tablet optimizations */
@media (min-width: 769px) and (max-width: 1024px) {
.home-view {
padding: 1rem 0.5rem;
}
.home-card {
min-height: 4.25rem;
padding: 0.75rem 1.25rem;
}
.home-card-icon {
width: 2.75rem;
height: 2.75rem;
}
}
/* Desktop optimizations */
@media (min-width: 1025px) {
.home-view {
padding: 1rem var(--margin-main);
max-width: 100%;
}
.home-hero {
text-align: left;
align-items: flex-start;
}
.home-logo-wrapper {
flex-direction: row;
justify-content: flex-start;
}
.home-desc {
font-size: 1.125rem;
max-width: 30rem;
text-align: left;
}
.home-cards {
flex-direction: row;
flex-wrap: wrap;
gap: 1.5rem;
max-width: 80rem;
}
.home-card {
min-height: 5rem;
padding: 1rem 1.5rem;
flex-direction: row;
}
}
.home-hero { .home-hero {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -19,7 +119,7 @@
} }
.home-logo-wrapper { .home-logo-wrapper {
display: flex; display: contents;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -33,11 +133,8 @@
} }
.home-brand-name { .home-brand-name {
font-size: 2rem; height: 2rem;
font-weight: 800; width: auto;
color: var(--primary);
letter-spacing: -0.02em;
line-height: 1;
} }
@media (max-height: 700px) { @media (max-height: 700px) {
@@ -87,15 +184,27 @@
} }
.home-card--search { .home-card--search {
background: var(--primary); /* Make suggestion/search buttons softer and more harmonious */
color: var(--on-primary); background: var(--primary-container);
box-shadow: 0 4px 16px rgba(0, 69, 13, 0.3); color: var(--on-primary-container);
box-shadow: 0 6px 18px rgba(13, 43, 18, 0.06);
} }
.home-card--scan { .home-card--scan {
background: var(--secondary); /* Keep the scan button vivid and easy to find (intentionally prominent) */
color: var(--on-secondary); background: #2b5bb5; /* vivid blue to stand out */
box-shadow: 0 4px 16px rgba(43, 91, 181, 0.3); color: #ffffff;
box-shadow: 0 8px 28px rgba(43, 91, 181, 0.28);
transform: translateY(0);
}
.home-card--scan:hover {
transform: translateY(-3px);
}
.home-card--scan:focus {
outline: none;
box-shadow: 0 0 0 6px rgba(43,91,181,0.12), 0 8px 28px rgba(43,91,181,0.28);
} }
.home-card-icon { .home-card-icon {
+2 -2
View File
@@ -6,8 +6,8 @@ function HomeView({ onScanClick, onSearchClick }) {
<div className="home-view"> <div className="home-view">
<div className="home-hero"> <div className="home-hero">
<div className="home-logo-wrapper"> <div className="home-logo-wrapper">
<img src="/logo.png" alt="FarmaClic" className="home-logo" /> <img src="/farmaclic_logo.png" alt="FarmaClic" className="home-logo" />
<h1 className="home-brand-name">FarmaClic</h1> <img src="/farmaclic_text.png" alt="FarmaClic" className="home-brand-name" />
</div> </div>
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p> <p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
</div> </div>
+35 -1
View File
@@ -300,7 +300,22 @@
background: rgba(186, 26, 26, 0.12); background: rgba(186, 26, 26, 0.12);
} }
@media (max-width: 400px) { @media (max-width: 480px) {
.profile-view {
padding: 1rem var(--margin-main) 1rem;
}
.profile-menu-item {
height: auto;
padding: 0.875rem 1rem;
min-height: 4rem;
}
.menu-item-icon {
width: 2.5rem;
height: 2.5rem;
}
.profile-field-row { .profile-field-row {
flex-direction: column; flex-direction: column;
gap: 0.85rem; gap: 0.85rem;
@@ -310,4 +325,23 @@
width: 6rem; width: 6rem;
height: 6rem; height: 6rem;
} }
.profile-btn-secondary {
width: 100%;
justify-content: center;
}
.profile-btn-primary {
width: 100%;
}
.profile-actions {
width: 100%;
}
.profile-logout-btn {
height: auto;
padding: 0.875rem 1rem;
min-height: 4rem;
}
} }
+15 -1
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import './ProfileView.css'; import './ProfileView.css';
import { getUserPosition } from '../utils/geo'; import { getUserPosition } from '../utils/geo';
function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) { function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdminClick }) {
const [address, setAddress] = useState(currentUser?.address || ''); const [address, setAddress] = useState(currentUser?.address || '');
const [latitude, setLatitude] = useState( const [latitude, setLatitude] = useState(
currentUser?.latitude != null ? String(currentUser.latitude) : '' currentUser?.latitude != null ? String(currentUser.latitude) : ''
@@ -158,6 +158,20 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
<polyline points="9 18 15 12 9 6" /> <polyline points="9 18 15 12 9 6" />
</svg> </svg>
</button> </button>
{currentUser?.is_admin && (
<button className="profile-menu-item" onClick={onAdminClick}>
<div className="menu-item-icon menu-item-icon--primary">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
</div>
<span className="menu-item-label">Panel de Administración</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
)}
</div> </div>
<div className="profile-location-section"> <div className="profile-location-section">
+84
View File
@@ -358,3 +358,87 @@
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* Photo upload section */
.upload-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upload-label {
font-size: 0.8rem;
color: var(--on-surface-variant);
font-weight: 600;
letter-spacing: 0.04em;
}
.upload-row {
display: flex;
gap: 0.75rem;
}
.upload-option {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.25rem 0.75rem;
background: var(--surface-container-lowest);
border: 2px solid var(--outline-variant);
border-radius: var(--radius-md);
cursor: pointer;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
font-size: 0.9rem;
font-weight: 600;
color: var(--on-surface);
transition: border-color 0.15s, background 0.15s;
}
.upload-option:hover {
border-color: var(--primary);
background: var(--surface-container);
}
.upload-option:active {
transform: scale(0.97);
}
.upload-option svg {
color: var(--primary);
}
.upload-hidden-input {
display: none;
}
/* Photo preview */
.photo-preview-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.photo-preview-container {
width: 100%;
border-radius: var(--radius-md);
overflow: hidden;
background: #000;
max-height: 22rem;
}
.photo-preview-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
max-height: 22rem;
}
.photo-preview-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
+205 -44
View File
@@ -1,10 +1,11 @@
import React, { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useRef } from 'react';
import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning'; import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import 'barcode-detector/polyfill'; import { BrowserMultiFormatReader } from '@zxing/browser';
import { BarcodeFormat as ZxingFormat, DecodeHintType } from '@zxing/library';
import './ScannerView.css'; import './ScannerView.css';
const CIP_REGEX = /^[A-Z0-9]{16}$/i; const CIP_REGEX = /^[A-Z0-9]{8,30}$/i;
function playBeep() { function playBeep() {
try { try {
@@ -29,6 +30,12 @@ function ScannerView({ onClose, onSelectMedicine }) {
const rafRef = useRef(null); const rafRef = useRef(null);
const detectorRef = useRef(null); const detectorRef = useRef(null);
// Log de diagnóstico al montar
React.useEffect(() => {
console.log('[Scanner] BarcodeDetector global:', typeof BarcodeDetector);
console.log('[Scanner] Navegador:', navigator.userAgent);
}, []);
const [phase, setPhase] = useState('idle'); const [phase, setPhase] = useState('idle');
const [manualCip, setManualCip] = useState(''); const [manualCip, setManualCip] = useState('');
const [prescriptions, setPrescriptions] = useState([]); const [prescriptions, setPrescriptions] = useState([]);
@@ -36,6 +43,12 @@ function ScannerView({ onClose, onSelectMedicine }) {
const [loadingPrescriptions, setLoadingPrescriptions] = useState(false); const [loadingPrescriptions, setLoadingPrescriptions] = useState(false);
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState('');
// Photo upload state
const [previewUrl, setPreviewUrl] = useState(null);
const [ocrLoading, setOcrLoading] = useState(false);
const cameraInputRef = useRef(null);
const galleryInputRef = useRef(null);
const isNative = Capacitor.isNativePlatform(); const isNative = Capacitor.isNativePlatform();
const fetchPrescriptions = useCallback(async (cip) => { const fetchPrescriptions = useCallback(async (cip) => {
@@ -112,6 +125,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
async function handleWebScan() { async function handleWebScan() {
setErrorMsg(''); setErrorMsg('');
console.log('[Scanner] Iniciando escaneo web...');
try { try {
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
setErrorMsg('Cámara no disponible en este navegador.'); setErrorMsg('Cámara no disponible en este navegador.');
@@ -119,58 +133,77 @@ function ScannerView({ onClose, onSelectMedicine }) {
return; return;
} }
let detector;
try {
detector = new BarcodeDetector({ formats: ['pdf417', 'code_128', 'qr_code'] });
} catch {
setErrorMsg('La detección de códigos no está soportada en este navegador.');
setPhase('error');
return;
}
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
}); });
console.log('[Scanner] Stream de cámara obtenido');
streamRef.current = stream; streamRef.current = stream;
detectorRef.current = detector;
setPhase('scanning'); setPhase('scanning');
await new Promise((resolve) => { await new Promise((resolve) => {
requestAnimationFrame(() => { const waitForVideo = () => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.srcObject = stream; videoRef.current.srcObject = stream;
videoRef.current.play().then(resolve).catch(resolve); videoRef.current.play().then(() => {
console.log('[Scanner] Video reproduciéndose');
console.log('[Scanner] Video dims:', videoRef.current.videoWidth, 'x', videoRef.current.videoHeight);
resolve();
}).catch((e) => {
console.error('[Scanner] Error al reproducir video:', e);
resolve();
});
} else { } else {
resolve(); requestAnimationFrame(waitForVideo);
} }
}); };
requestAnimationFrame(waitForVideo);
}); });
let found = false; console.log('[Scanner] Usando zxing local (@zxing/browser)...');
async function scanFrame() { const hints = new Map();
if (found || !videoRef.current || !detectorRef.current) return; hints.set(DecodeHintType.POSSIBLE_FORMATS, [
try { ZxingFormat.CODE_128,
const barcodes = await detectorRef.current.detect(videoRef.current); ZxingFormat.CODE_39,
if (barcodes.length > 0) { ZxingFormat.CODE_93,
const rawValue = barcodes[0].rawValue; ZxingFormat.PDF_417,
if (rawValue && CIP_REGEX.test(rawValue)) { ZxingFormat.QR_CODE,
found = true; ZxingFormat.EAN_13,
stopCamera(); ZxingFormat.EAN_8,
playBeep(); ZxingFormat.UPC_A,
setScannedCip(rawValue); ZxingFormat.UPC_E,
setPhase('prescriptions'); ZxingFormat.ITF,
fetchPrescriptions(rawValue); ]);
return; hints.set(DecodeHintType.TRY_HARDER, true);
} const zxingReader = new BrowserMultiFormatReader(hints);
detectorRef.current = zxingReader;
const formatNames = { 0:'AZTEC', 1:'CODABAR', 2:'CODE_39', 3:'CODE_93', 4:'CODE_128', 5:'DATA_MATRIX', 6:'EAN_8', 7:'EAN_13', 8:'ITF', 10:'PDF_417', 11:'QR_CODE', 12:'RSS_14', 14:'UPC_A', 15:'UPC_E' };
console.log('[Scanner] Iniciando decodeFromVideoElement...');
const controls = await zxingReader.decodeFromVideoElement(videoRef.current, (result, error) => {
if (result) {
const rawValue = result.getText();
const cleaned = rawValue.replace(/[^A-Z0-9]/gi, '');
const format = result.getBarcodeFormat();
console.log('[Scanner] zxing detectó! raw:', rawValue, '| limpio:', cleaned, '| formato:', formatNames[format] || format);
if (cleaned && CIP_REGEX.test(cleaned)) {
console.log('[Scanner] CIP válido ✓');
controls.stop();
stopCamera();
playBeep();
setScannedCip(cleaned);
setPhase('prescriptions');
fetchPrescriptions(cleaned);
} else if (rawValue) {
console.log('[Scanner] Código no cumple regex:', cleaned);
} }
} catch { }
if (!found) {
rafRef.current = requestAnimationFrame(scanFrame);
} }
} });
scanFrame(); console.log('[Scanner] Escaneando...');
} catch (err) { } catch (err) {
console.error('[Scanner] Error general:', err);
stopCamera(); stopCamera();
if (err.name === 'NotAllowedError') { if (err.name === 'NotAllowedError') {
setErrorMsg('Permiso de cámara denegado. Permite el acceso e intenta de nuevo.'); setErrorMsg('Permiso de cámara denegado. Permite el acceso e intenta de nuevo.');
@@ -210,6 +243,61 @@ function ScannerView({ onClose, onSelectMedicine }) {
fetchPrescriptions(cip); fetchPrescriptions(cip);
} }
// Photo upload handlers
function handlePhotoSelect(e) {
const file = e.target.files?.[0];
if (!file) return;
setErrorMsg('');
setPreviewUrl(URL.createObjectURL(file));
setPhase('photo-preview');
// Reset input so selecting the same file again triggers onChange
e.target.value = '';
}
async function handlePhotoOcr() {
if (!previewUrl) return;
setOcrLoading(true);
setErrorMsg('');
try {
const res = await fetch(previewUrl);
const blob = await res.blob();
const formData = new FormData();
formData.append('photo', blob, 'tsi.jpg');
const ocrRes = await fetch('/api/tsi/ocr', { method: 'POST', body: formData });
const data = await ocrRes.json();
if (!ocrRes.ok) {
setErrorMsg(data.error || 'No se pudo leer la imagen. Intenta con otra foto.');
setPhase('error');
return;
}
const cip = data.cip;
if (!CIP_REGEX.test(cip)) {
setErrorMsg(`CIP detectado "${cip}" no tiene formato válido. Introduce el código manualmente.`);
setPhase('error');
return;
}
playBeep();
setScannedCip(cip);
setPreviewUrl(null);
setPhase('prescriptions');
fetchPrescriptions(cip);
} catch (err) {
setErrorMsg(`Error al procesar la imagen: ${err.message || 'Error desconocido'}`);
setPhase('error');
} finally {
setOcrLoading(false);
}
}
function handlePhotoDiscard() {
setPreviewUrl(null);
setPhase('idle');
}
function handlePickPrescription(rx) { function handlePickPrescription(rx) {
stopCamera(); stopCamera();
onSelectMedicine(rx.name); onSelectMedicine(rx.name);
@@ -221,6 +309,9 @@ function ScannerView({ onClose, onSelectMedicine }) {
setPhase('idle'); setPhase('idle');
setPrescriptions([]); setPrescriptions([]);
setScannedCip(null); setScannedCip(null);
} else if (phase === 'photo-preview') {
setPreviewUrl(null);
setPhase('idle');
} else { } else {
onClose(); onClose();
} }
@@ -258,13 +349,51 @@ function ScannerView({ onClose, onSelectMedicine }) {
)} )}
{phase === 'idle' && ( {phase === 'idle' && (
<button className="scan-btn scan-btn--primary scan-btn--start" onClick={handleStartScan}> <>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <button className="scan-btn scan-btn--primary scan-btn--start" onClick={handleStartScan}>
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" /> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="13" r="4" /> <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
</svg> <circle cx="12" cy="13" r="4" />
Abrir cámara </svg>
</button> Abrir cámara
</button>
<div className="upload-section">
<label className="upload-label">O sube una foto de tu TSI</label>
<div className="upload-row">
<button className="upload-option" onClick={() => cameraInputRef.current?.click()}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<span>Hacer foto</span>
</button>
<button className="upload-option" onClick={() => galleryInputRef.current?.click()}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span>Subir de galería</span>
</button>
</div>
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handlePhotoSelect}
className="upload-hidden-input"
/>
<input
ref={galleryInputRef}
type="file"
accept="image/*"
onChange={handlePhotoSelect}
className="upload-hidden-input"
/>
</div>
</>
)} )}
{phase === 'scanning' && !isNative && ( {phase === 'scanning' && !isNative && (
@@ -287,6 +416,38 @@ function ScannerView({ onClose, onSelectMedicine }) {
</div> </div>
)} )}
{phase === 'photo-preview' && (
<div className="photo-preview-panel">
<div className="photo-preview-container">
<img src={previewUrl} alt="TSI capturada" className="photo-preview-img" />
</div>
{ocrLoading ? (
<div className="scanning-active">
<div className="scanner-spinner" />
<p>Procesando imagen</p>
</div>
) : (
<div className="photo-preview-actions">
<button className="scan-btn scan-btn--primary scan-btn--start" onClick={handlePhotoOcr}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 7 4 4 20 4 20 7" />
<line x1="9" y1="20" x2="15" y2="20" />
<line x1="12" y1="4" x2="12" y2="20" />
</svg>
Escanear imagen
</button>
<button className="scan-btn scan-btn--ghost" onClick={handlePhotoDiscard}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
Descartar
</button>
</div>
)}
</div>
)}
<form className="cip-form" onSubmit={handleManualSubmit}> <form className="cip-form" onSubmit={handleManualSubmit}>
<label className="cip-label" htmlFor="cip-input">O introduce el código CIP manualmente</label> <label className="cip-label" htmlFor="cip-input">O introduce el código CIP manualmente</label>
<div className="cip-row"> <div className="cip-row">
+23 -13
View File
@@ -44,31 +44,41 @@
transform: scale(0.96); transform: scale(0.96);
} }
.suggestion-btn--primary { .suggestion-btn:hover {
background: var(--primary); background: #83afd6;
color: var(--on-primary); font-weight: 700;
} }
.suggestion-btn--secondary { .suggestion-btn--neutral-1 {
background: var(--secondary); background: #ffffff;
color: var(--on-secondary); color: #1e293b;
border: 1px solid #e2e8f0;
} }
.suggestion-btn--tertiary { .suggestion-btn--neutral-2 {
background: var(--tertiary-container); background: #f0f7ff;
color: var(--on-tertiary); color: #1e293b;
border: 1px solid #dbeafe;
} }
.suggestion-btn--tint { .suggestion-btn--neutral-3 {
background: var(--surface-tint); background: #e0f0ff;
color: var(--on-primary); color: #1e293b;
border: 1px solid #bfdbfe;
}
.suggestion-btn--neutral-4 {
background: #d4ebff;
color: #1e293b;
border: 1px solid #93c5fd;
} }
.suggestion-icon { .suggestion-icon {
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
border-radius: var(--radius); border-radius: var(--radius);
background: rgba(255, 255, 255, 0.2); background: rgba(59, 130, 246, 0.08);
color: #3b82f6;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+87 -39
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import SearchBar from '../components/SearchBar'; import SearchBar from '../components/SearchBar';
import MedicineResults from '../components/MedicineResults'; import MedicineResults from '../components/MedicineResults';
import PharmacyList from '../components/PharmacyList'; import PharmacyList from '../components/PharmacyList';
@@ -7,15 +7,10 @@ import { haversineKm, getUserPosition } from '../utils/geo';
import './SearchView.css'; import './SearchView.css';
const suggestions = [ const suggestions = [
{ name: 'Paracetamol', icon: 'medication', color: 'primary' }, { name: 'Paracetamol', icon: 'medication', color: 'neutral-1' },
{ name: 'Ibuprofeno', icon: 'pill', color: 'secondary' }, { name: 'Ibuprofeno', icon: 'pill', color: 'neutral-2' },
{ name: 'Aspirina', icon: 'vaccines', color: 'tertiary' }, { name: 'Aspirina', icon: 'vaccines', color: 'neutral-3' },
{ name: 'Omeprazol', icon: 'emergency_home', color: 'tint' }, { name: 'Omeprazol', icon: 'emergency_home', color: 'neutral-4' },
];
const recentResults = [
{ name: 'Amoxicilina', detail: '500mg • 24 Cápsulas', tag: 'Antibiótico', distance: '300m - Farmacia Central' },
{ name: 'Losartán', detail: '50mg • 30 Comprimidos', tag: 'Hipertensión', distance: '1.2km - Farmacia del Sol' },
]; ];
function SearchView({ currentUser, onLoginRequest }) { function SearchView({ currentUser, onLoginRequest }) {
@@ -29,6 +24,44 @@ function SearchView({ currentUser, onLoginRequest }) {
const [sortByDistance, setSortByDistance] = useState(false); const [sortByDistance, setSortByDistance] = useState(false);
const [locating, setLocating] = useState(false); const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState(''); const [locationError, setLocationError] = useState('');
const [recentSearches, setRecentSearches] = useState([]);
// Fetch recent searches when user is logged in
useEffect(() => {
if (!currentUser) {
setRecentSearches([]);
return;
}
fetch('/api/search/recent', { credentials: 'include' })
.then((r) => r.json())
.then((data) => setRecentSearches(Array.isArray(data) ? data : []))
.catch(() => setRecentSearches([]));
}, [currentUser]);
const saveToRecent = useCallback((medicine) => {
if (!currentUser) return;
fetch('/api/search/recent', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ medicine }),
}).catch(() => {});
// Update local state immediately
setRecentSearches((prev) => {
const filtered = prev.filter((m) => m.id !== medicine.id);
return [
{
id: medicine.id,
name: medicine.name,
active_ingredient: medicine.active_ingredient,
dosage: medicine.dosage,
form: medicine.form,
timestamp: Date.now(),
},
...filtered,
].slice(0, 10);
});
}, [currentUser]);
useEffect(() => { useEffect(() => {
const searchMedicines = async () => { const searchMedicines = async () => {
@@ -157,43 +190,58 @@ function SearchView({ currentUser, onLoginRequest }) {
</div> </div>
</section> </section>
<section className="recent-section"> {currentUser && recentSearches.length > 0 && (
<h2 className="section-title">Resultados Recientes</h2> <section className="recent-section">
<div className="recent-list"> <h2 className="section-title">Resultados Recientes</h2>
{recentResults.map((r, i) => ( <div className="recent-list">
<div key={i} className="recent-card"> {recentSearches.map((r) => (
<div className="recent-card-top"> <div
<div> key={r.id}
<h3 className="recent-name">{r.name}</h3> className="recent-card"
<p className="recent-detail">{r.detail}</p> onClick={() => setSelectedMedicine(r)}
style={{ cursor: 'pointer' }}
>
<div className="recent-card-top">
<div>
<h3 className="recent-name">{r.name}</h3>
<p className="recent-detail">
{r.dosage && `${r.dosage}`}
{r.dosage && r.form && ' \u2022 '}
{r.form}
</p>
</div>
{r.active_ingredient && (
<span className="recent-tag">{r.active_ingredient}</span>
)}
</div> </div>
<span className="recent-tag">{r.tag}</span> <button
className="recent-btn"
onClick={(e) => {
e.stopPropagation();
setSelectedMedicine(r);
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Encontrar cerca
</button>
</div> </div>
<div className="recent-distance"> ))}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> </div>
<circle cx="12" cy="12" r="10" /> </section>
<polyline points="12 6 12 12 16 14" /> )}
</svg>
<span>{r.distance}</span>
</div>
<button className="recent-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Encontrar cerca
</button>
</div>
))}
</div>
</section>
</> </>
)} )}
{searchQuery && !selectedMedicine && ( {searchQuery && !selectedMedicine && (
<MedicineResults <MedicineResults
medicines={medicines} medicines={medicines}
onSelect={setSelectedMedicine} onSelect={(m) => {
saveToRecent(m);
setSelectedMedicine(m);
}}
query={searchQuery} query={searchQuery}
currentUser={currentUser} currentUser={currentUser}
onLoginRequest={onLoginRequest} onLoginRequest={onLoginRequest}
+2 -4
View File
@@ -20,14 +20,12 @@ export default defineConfig({
name: 'FarmaClic', name: 'FarmaClic',
short_name: 'FarmaClic', short_name: 'FarmaClic',
description: 'Encuentra medicamentos en farmacias cercanas', description: 'Encuentra medicamentos en farmacias cercanas',
theme_color: '#00450d', theme_color: '#7fbf8f',
background_color: '#f8fafb', background_color: '#f8fafb',
display: 'standalone', display: 'standalone',
start_url: '/', start_url: '/',
icons: [ icons: [
{ src: '/logo-square-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: '/farmaclic_logo.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
{ src: '/logo-square-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/logo.png', sizes: '512x279', type: 'image/png', purpose: 'any' },
], ],
}, },
}), }),