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
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 34 KiB |
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,8 +6,8 @@ function HomeView({ onScanClick, onSearchClick }) {
|
||||
<div className="home-view">
|
||||
<div className="home-hero">
|
||||
<div className="home-logo-wrapper">
|
||||
<img src="/logo.png" alt="FarmaClic" className="home-logo" />
|
||||
<h1 className="home-brand-name">FarmaClic</h1>
|
||||
<img src="/farmaclic_logo.png" alt="FarmaClic" className="home-logo" />
|
||||
<img src="/farmaclic_text.png" alt="FarmaClic" className="home-brand-name" />
|
||||
</div>
|
||||
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' && (
|
||||
<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">
|
||||
<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>
|
||||
Abrir cámara
|
||||
</button>
|
||||
<>
|
||||
<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">
|
||||
<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>
|
||||
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 && (
|
||||
@@ -314,6 +416,38 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
</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}>
|
||||
<label className="cip-label" htmlFor="cip-input">O introduce el código CIP manualmente</label>
|
||||
<div className="cip-row">
|
||||
|
||||