diff --git a/backend/package-lock.json b/backend/package-lock.json index 8154e18..fd54a61 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "farma-finder-backend", + "name": "farma-clic-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "farma-finder-backend", + "name": "farma-clic-backend", "version": "1.0.0", "license": "ISC", "dependencies": { @@ -27,11 +27,13 @@ "express": "^4.18.2", "express-rate-limit": "^8.5.2", "express-session": "^1.17.3", + "multer": "^2.2.0", "pg": "^8.13.0", "pino": "^9.4.0", "pino-http": "^10.3.0", "redis": "^4.6.0", "sqlite3": "^5.1.6", + "tesseract.js": "^7.0.0", "web-push": "^3.6.7" }, "devDependencies": { @@ -3727,6 +3729,11 @@ "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": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", @@ -4020,6 +4027,11 @@ "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": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", @@ -4151,9 +4163,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4426,6 +4448,20 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "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": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", @@ -5682,6 +5718,11 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5870,6 +5911,11 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7056,6 +7102,24 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "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": { "version": "2.0.0", "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" } }, + "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7915,6 +7987,11 @@ "@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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -8477,6 +8554,14 @@ "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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8718,6 +8803,28 @@ "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -8824,6 +8931,11 @@ "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": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", @@ -8964,6 +9076,11 @@ "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": { "version": "3.6.7", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", @@ -9168,6 +9285,14 @@ "funding": { "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": "*" + } } } } diff --git a/backend/package.json b/backend/package.json index 43c6ef2..8a86a18 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,15 +32,17 @@ "bcrypt": "^5.1.1", "connect-pg-simple": "^10.0.0", "connect-sqlite3": "^0.9.16", - "pg": "^8.13.0", "cors": "^2.8.5", "express": "^4.18.2", "express-rate-limit": "^8.5.2", "express-session": "^1.17.3", + "multer": "^2.2.0", + "pg": "^8.13.0", "pino": "^9.4.0", "pino-http": "^10.3.0", "redis": "^4.6.0", "sqlite3": "^5.1.6", + "tesseract.js": "^7.0.0", "web-push": "^3.6.7" }, "devDependencies": { diff --git a/backend/server.js b/backend/server.js index 6bd53f8..1a45bcc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -16,6 +16,8 @@ import rateLimit from 'express-rate-limit'; import webpush from 'web-push'; import pino from 'pino'; import pinoHttp from 'pino-http'; +import multer from 'multer'; +import { createWorker } from 'tesseract.js'; import { searchMedicines, getMedicineDetails } from './cima-service.js'; import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js'; import { fetchPharmaciesExternal } from '../API/index.js'; @@ -515,6 +517,57 @@ app.get('/api/tsi/:cip/prescriptions', async (req, res) => { } }); +// ========== TSI OCR: extract CIP from uploaded photo ========== + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB max + fileFilter: (_req, file, cb) => { + if (file.mimetype.startsWith('image/')) cb(null, true); + else cb(new Error('Only image files are accepted')); + }, +}); + +// Extract CIP from OCR text — looks for a long alphanumeric token (16 chars typical for CIP) +function extractCip(text) { + const cleaned = text.replace(/\s+/g, ''); + // Try exact 16-char alphanumeric match first + const exact = cleaned.match(/[A-Z0-9]{16}/i); + if (exact) return exact[0].toUpperCase(); + // Fallback: 8–30 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' }); + } +}); + // ========== AUTHENTICATION ROUTES ========== // Login endpoint diff --git a/frontend/public/app-icon.png b/frontend/public/app-icon.png index 4c1436b..9995a87 100644 Binary files a/frontend/public/app-icon.png and b/frontend/public/app-icon.png differ diff --git a/frontend/public/farmaclic_logo.png b/frontend/public/farmaclic_logo.png new file mode 100644 index 0000000..3a5ee83 Binary files /dev/null and b/frontend/public/farmaclic_logo.png differ diff --git a/frontend/public/farmaclic_text.png b/frontend/public/farmaclic_text.png new file mode 100644 index 0000000..6966c7e Binary files /dev/null and b/frontend/public/farmaclic_text.png differ diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png index 98c153c..c99dc13 100644 Binary files a/frontend/public/favicon.png and b/frontend/public/favicon.png differ diff --git a/frontend/public/logo-square-192.png b/frontend/public/logo-square-192.png index f2d1f9d..d709899 100644 Binary files a/frontend/public/logo-square-192.png and b/frontend/public/logo-square-192.png differ diff --git a/frontend/public/logo-square-512.png b/frontend/public/logo-square-512.png index cdf375d..2f76355 100644 Binary files a/frontend/public/logo-square-512.png and b/frontend/public/logo-square-512.png differ diff --git a/frontend/public/logo.png b/frontend/public/logo.png index cdf375d..8ef1d16 100644 Binary files a/frontend/public/logo.png and b/frontend/public/logo.png differ diff --git a/frontend/src/index.css b/frontend/src/index.css index fd2f82f..813aa39 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -30,7 +30,7 @@ --inverse-surface: #2e3132; --inverse-on-surface: #eff1f2; --inverse-primary: #91d78a; - --surface-tint: #2a6b2c; + --surface-tint: #8ccb8e; --surface-dim: #d8dadb; --surface-bright: #f8fafb; --surface-variant: #e1e3e4; diff --git a/frontend/src/views/HomeView.css b/frontend/src/views/HomeView.css index e4b0bf6..df64682 100644 --- a/frontend/src/views/HomeView.css +++ b/frontend/src/views/HomeView.css @@ -133,11 +133,8 @@ } .home-brand-name { - font-size: 2rem; - font-weight: 800; - color: var(--primary); - letter-spacing: -0.02em; - line-height: 1; + height: 2rem; + width: auto; } @media (max-height: 700px) { diff --git a/frontend/src/views/HomeView.jsx b/frontend/src/views/HomeView.jsx index fd6c16f..9cbfc59 100644 --- a/frontend/src/views/HomeView.jsx +++ b/frontend/src/views/HomeView.jsx @@ -6,8 +6,8 @@ function HomeView({ onScanClick, onSearchClick }) {
- FarmaClic -

FarmaClic

+ FarmaClic + FarmaClic

Encuentra tus medicamentos en farmacias cercanas

diff --git a/frontend/src/views/ScannerView.css b/frontend/src/views/ScannerView.css index 920844a..e04c0d4 100644 --- a/frontend/src/views/ScannerView.css +++ b/frontend/src/views/ScannerView.css @@ -358,3 +358,87 @@ @keyframes spin { 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; +} diff --git a/frontend/src/views/ScannerView.jsx b/frontend/src/views/ScannerView.jsx index e761c95..7e2ab17 100644 --- a/frontend/src/views/ScannerView.jsx +++ b/frontend/src/views/ScannerView.jsx @@ -43,6 +43,12 @@ function ScannerView({ onClose, onSelectMedicine }) { const [loadingPrescriptions, setLoadingPrescriptions] = useState(false); 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 fetchPrescriptions = useCallback(async (cip) => { @@ -237,6 +243,61 @@ function ScannerView({ onClose, onSelectMedicine }) { 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) { stopCamera(); onSelectMedicine(rx.name); @@ -248,6 +309,9 @@ function ScannerView({ onClose, onSelectMedicine }) { setPhase('idle'); setPrescriptions([]); setScannedCip(null); + } else if (phase === 'photo-preview') { + setPreviewUrl(null); + setPhase('idle'); } else { onClose(); } @@ -285,13 +349,51 @@ function ScannerView({ onClose, onSelectMedicine }) { )} {phase === 'idle' && ( - + <> + + +
+ +
+ + +
+ + +
+ )} {phase === 'scanning' && !isNative && ( @@ -314,6 +416,38 @@ function ScannerView({ onClose, onSelectMedicine }) {
)} + {phase === 'photo-preview' && ( +
+
+ TSI capturada +
+ {ocrLoading ? ( +
+
+

Procesando imagen…

+
+ ) : ( +
+ + +
+ )} +
+ )} +