12 Commits

Author SHA1 Message Date
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
29 changed files with 1212 additions and 605 deletions
-20
View File
@@ -45,23 +45,3 @@ jobs:
- name: Run Frontend Tests
run: cd frontend && npm test -- --run --reporter=basic
test-pip:
name: PIP Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: pip-platform/package-lock.json
- name: Install PIP dependences
run: cd pip-platform && npm ci
- name: Run PIP Tests
run: cd pip-platform && npm test -- --run --reporter=basic
+203 -25
View File
@@ -65,7 +65,8 @@ const sessionConfig = {
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
secure: process.env.COOKIE_SECURE === 'true',
sameSite: 'lax',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
@@ -138,6 +139,14 @@ async function userDbRun(sql, 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
// and return a TEXT value (or null) safe to persist.
function serializeOpeningHours(value) {
@@ -225,6 +234,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(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -266,9 +291,28 @@ async function initDatabase() {
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 {}
console.log('Database initialized successfully');
} catch (error) {
console.error('Error initializing database:', error);
// Create alerts table for persistent Avisos data
try {
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 +384,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)
app.get('/api/pharmacies', async (req, res) => {
try {
@@ -402,27 +515,6 @@ app.get('/api/tsi/:cip/prescriptions', 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' });
};
// ========== AUTHENTICATION ROUTES ==========
// Login endpoint
@@ -630,6 +722,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.
app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => {
const q = (req.query.q || '').trim();
+10 -8
View File
@@ -7,18 +7,20 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: farmaclic
POSTGRES_USER: farmaclic
POSTGRES_DB: farmafinder
POSTGRES_USER: farmafinder
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
volumes:
- postgres_data:/var/lib/postgresql/data
backend:
image: git.hacecalor.net/ichitux/farmaclic-backend:latest
image: git.hacecalor.net/ichitux/farmafinder-backend:latest
build:
context: .
dockerfile: backend/Dockerfile
restart: unless-stopped
env_file:
- ./backend/.env
ports:
- "3001:3001"
environment:
@@ -31,13 +33,13 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
DATABASE_PATH: /app/data/database.sqlite
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
OTEL_SERVICE_NAME: farmaclic-backend
OTEL_SERVICE_NAME: farmafinder-backend
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
OTEL_TRACES_EXPORTER: otlp
OTEL_LOGS_EXPORTER: otlp
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmaclic
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmafinder
volumes:
- backend_data:/app/data
depends_on:
@@ -45,12 +47,12 @@ services:
- postgres
frontend:
image: git.hacecalor.net/ichitux/farmaclic-frontend:latest
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
build:
context: ./frontend
args:
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_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
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
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<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>
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
+43 -53
View File
@@ -1,11 +1,11 @@
{
"name": "farma-finder-frontend",
"name": "farma-clic-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "farma-finder-frontend",
"name": "farma-clic-frontend",
"version": "1.0.0",
"dependencies": {
"@capacitor-mlkit/barcode-scanning": "^8.1.0",
@@ -17,7 +17,8 @@
"@grafana/faro-transport-otlp-http": "^1.7.0",
"@grafana/faro-web-sdk": "^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",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -3467,11 +3468,6 @@
"@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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3696,6 +3692,37 @@
"dev": true,
"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": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3943,14 +3970,6 @@
"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": {
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
@@ -7490,17 +7509,6 @@
"dev": true,
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -7635,6 +7643,14 @@
"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": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -8564,32 +8580,6 @@
"funding": {
"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-web-sdk": "^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",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+127 -7
View File
@@ -9,21 +9,141 @@
.app-content {
flex: 1;
min-height: 0;
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) {
.app-logo-wrapper {
flex-direction: column;
}
.home-card--scan {
min-height: 4.25rem;
padding: 0.75rem 1.25rem;
}
.home-card-icon {
width: 2.75rem;
height: 2.75rem;
}
}
/* Tablet-specific styles */
@media (min-width: 769px) and (max-width: 1024px) {
.home-card {
min-height: 4rem;
padding: 0.625rem 1rem;
}
}
/* Desktop-specific styles */
@media (min-width: 1025px) {
.app-content {
padding-bottom: calc(6rem + env(safe-area-inset-bottom));
padding: 0 5rem;
}
.home-card {
min-height: 3.5rem;
padding: 0.5rem 0.875rem;
}
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
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;
}
@keyframes spin {
to { transform: rotate(360deg); }
/* 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 LoginModal from './components/LoginModal';
import SavedNotifications from './components/SavedNotifications';
import TopBar from './components/TopBar';
import BottomNav from './components/BottomNav';
function App() {
@@ -17,13 +16,33 @@ function App() {
const [authChecked, setAuthChecked] = useState(false);
const [showLogin, setShowLogin] = 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(() => {
// Set initial screen size
const handleResize = () => {
setScreenSize({
width: window.innerWidth,
height: window.innerHeight
});
};
fetch('/api/auth/check')
.then(r => r.json())
.then(data => { if (data.authenticated) setCurrentUser(data.user); })
.catch(() => {})
.finally(() => setAuthChecked(true));
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
function handleLogin(user) {
@@ -41,6 +60,10 @@ function App() {
setCurrentUser(prev => ({ ...prev, ...updated }));
}
function handleAdminClick() {
setScreen('admin');
}
const isLoggedIn = Boolean(currentUser);
function handleNavChange(tab) {
@@ -59,13 +82,8 @@ function App() {
}
}
function handleBack() {
setScreen('home');
}
let activeView;
let topBarTitle = 'FarmaClic';
let showBack = false;
let deviceClass = isMobile ? 'mobile-view' : 'desktop-view';
switch (screen) {
case 'profile':
@@ -75,13 +93,12 @@ function App() {
onProfileSaved={handleProfileSaved}
onShowSaved={() => setShowSaved(true)}
onLogout={handleLogout}
onAdminClick={handleAdminClick}
/>
);
showBack = true;
break;
case 'alerts':
activeView = <AlertsView />;
showBack = true;
break;
case 'search':
activeView = (
@@ -90,7 +107,6 @@ function App() {
onLoginRequest={() => setShowLogin(true)}
/>
);
showBack = true;
break;
case 'scan':
activeView = (
@@ -101,12 +117,9 @@ function App() {
}}
/>
);
showBack = false;
break;
case 'admin':
activeView = <AdminView />;
topBarTitle = 'Admin';
showBack = true;
break;
default:
activeView = (
@@ -117,18 +130,11 @@ function App() {
onSearchClick={() => setScreen('search')}
/>
);
showBack = false;
break;
}
return (
<div className="app">
<TopBar
title={topBarTitle}
showBack={showBack}
onBack={handleBack}
/>
<div className={`app ${deviceClass}`}>
<div className="app-content">
{activeView}
</div>
@@ -136,7 +142,7 @@ function App() {
<BottomNav
activeTab={screen}
onChange={handleNavChange}
isLoggedIn={isLoggedIn}
isLoggedIn={Boolean(currentUser)}
badgeCount={currentUser ? 2 : 0}
/>
+28 -14
View File
@@ -1,22 +1,26 @@
.bottom-nav {
display: none;
}
@media (max-width: 768px) {
.bottom-nav {
display: flex;
justify-content: space-around;
align-items: flex-end;
position: fixed;
left: 0;
right: 0;
bottom: 0;
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;
}
/* Desktop navigation - full width */
@media (min-width: 1025px) {
.bottom-nav {
max-width: 100%;
padding: 0.25rem 2rem calc(0.5rem + env(safe-area-inset-bottom));
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
}
.bottom-nav-item {
@@ -60,17 +64,28 @@
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);
/* 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.15s;
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;
@@ -120,4 +135,3 @@
.nav-elevated.active .nav-label {
color: var(--tertiary);
}
}
@@ -123,16 +123,16 @@
gap: 0.25rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: rgba(37, 99, 235, 0.08);
color: var(--primary, #2563eb);
background: var(--secondary-container, #dbe7ff);
color: var(--on-secondary, #06204a);
font-size: 0.75rem;
font-weight: 600;
width: fit-content;
}
.saved-notifications-chip--pharmacy {
background: rgba(4, 120, 87, 0.1);
color: var(--accent, #047857);
background: var(--primary-container, #cfead0);
color: var(--on-primary, #09310a);
}
.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;
+32 -23
View File
@@ -1,28 +1,29 @@
:root {
--primary: #00450d;
--on-primary: #ffffff;
--primary-container: #1b5e20;
--on-primary-container: #90d689;
--secondary: #2b5bb5;
--on-secondary: #ffffff;
--secondary-container: #759efd;
--on-secondary-container: #00337c;
--tertiary: #4600ad;
--on-tertiary: #ffffff;
--tertiary-container: #6100e8;
--on-tertiary-container: #cebbff;
--surface: #f8fafb;
--on-surface: #191c1d;
/* Pastel, softer palette */
--primary: #7fbf8f; /* soft green */
--on-primary: #09310a; /* dark accent for contrast */
--primary-container: #cfead0;
--on-primary-container: #0d2b12;
--secondary: #a3b8ff; /* soft blue */
--on-secondary: #06204a;
--secondary-container: #dbe7ff;
--on-secondary-container: #0b2a66;
--tertiary: #c9b3ff; /* soft lavender */
--on-tertiary: #241342;
--tertiary-container: #efe7ff;
--on-tertiary-container: #3b2a5a;
--surface: #fbfbfb;
--on-surface: #111417;
--on-surface-variant: #41493e;
--surface-container-lowest: #ffffff;
--surface-container-low: #f2f4f5;
--surface-container: #eceeef;
--surface-container-high: #e6e8e9;
--surface-container-highest: #e1e3e4;
--error: #ba1a1a;
--error: #b91c1c;
--on-error: #ffffff;
--error-container: #ffdad6;
--on-error-container: #93000a;
--error-container: #feecec;
--on-error-container: #610909;
--outline: #717a6d;
--outline-variant: #c0c9bb;
--on-background: #191c1d;
@@ -64,12 +65,12 @@
--glass-border: var(--outline-variant);
--glass-shadow: var(--shadow-soft);
--accent: var(--primary);
--accent-warm: #b45309;
--primary-hover: #1a6b2e;
--primary-ring: rgba(0, 69, 13, 0.22);
--primary-shadow: rgba(0, 69, 13, 0.18);
--primary-light: var(--primary-fixed);
--primary-faint: rgba(0, 69, 13, 0.08);
--accent-warm: #f5a97a;
--primary-hover: #6fb47f; /* slightly darker than primary */
--primary-ring: rgba(127, 191, 143, 0.22);
--primary-shadow: rgba(127, 191, 143, 0.12);
--primary-light: #eaf7ec;
--primary-faint: rgba(127, 191, 143, 0.06);
}
* {
@@ -108,6 +109,14 @@ body::before {
z-index: -1;
}
body::after {
content: "";
position: fixed;
inset: 0;
background-color: rgba(255, 252, 245, 0.48);
z-index: -1;
}
#root {
height: 100dvh;
overflow: hidden;
+1 -1
View File
@@ -13,7 +13,7 @@ export async function initNativeShell() {
try {
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(() => {});
} catch {
/* plugin not present — fine on web */
+3 -1
View File
@@ -35,7 +35,7 @@ export function pushSupported() {
}
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) {
const message = res.status === 503
? '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 res = await fetch('/api/notifications/subscribe', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
medicine_nregistro: medicineNregistro,
@@ -115,6 +116,7 @@ export async function unsubscribeFromPush(medicineNregistro, pharmacyId = null)
if (endpoint) {
await fetch('/api/notifications/unsubscribe', {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
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--secondary { border-color: rgba(43, 91, 181, 0.1); }
.alert-card--tertiary { border-color: rgba(70, 0, 173, 0.1); }
.alert-card-top {
@@ -68,11 +67,6 @@
color: var(--on-primary-container);
}
.alert-icon--secondary {
background: var(--secondary-container);
color: var(--on-secondary-container);
}
.alert-icon--tertiary {
background: var(--tertiary-container);
color: var(--on-tertiary);
@@ -106,11 +100,6 @@
color: var(--on-error-container);
}
.alert-badge--secondary {
background: var(--secondary-fixed);
color: var(--on-secondary-fixed-variant);
}
.alert-badge--tertiary {
background: var(--tertiary-fixed);
color: var(--on-tertiary-fixed-variant);
@@ -122,36 +111,6 @@
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 {
width: var(--touch-target-min);
height: var(--touch-target-min);
+122 -53
View File
@@ -1,36 +1,6 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
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 = {
bell: (
<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" />
</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 (
<div className="alerts-view">
<main className="alerts-main">
@@ -58,30 +98,54 @@ function AlertsView({ onBack }) {
<p className="alerts-subtitle">Mantente al día con tus medicamentos.</p>
</div>
{loading && <p className="alerts-status" style={{ textAlign: 'center', padding: '2rem' }}>Cargando avisos...</p>}
{!loading && error && <p className="alerts-error" style={{ color: 'var(--error)', textAlign: 'center', padding: '2rem' }}>{error}</p>}
{!loading && !error && (
<>
{/* Availability Section */}
<section className="alerts-section">
<h3 className="alerts-section-title" style={{ marginBottom: '1rem' }}>Disponibilidad</h3>
<div className="alerts-list">
{alerts.map((alert) => (
<div key={alert.id} className={`alert-card alert-card--${alert.color}`}>
{availability.length === 0 ? (
<p className="alerts-empty" style={{ color: 'var(--on-surface-variant)', fontStyle: 'italic' }}>No tienes suscripciones de disponibilidad.</p>
) : (
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--${alert.color}`}>
{iconMap[alert.icon]}
<div className={`alert-icon alert-icon--${cardColor}`}>
{iconMap[cardIcon]}
</div>
<div className="alert-info">
<h3 className="alert-title">{alert.title}</h3>
<span className={`alert-badge alert-badge--${alert.color}`}>
{alert.detail}
<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">
{alert.actions.map((action, i) => (
<div className="alert-actions" style={{ justifyContent: 'flex-end' }}>
<button
key={i}
className={`alert-btn alert-btn--${action.variant}`}
className="alert-delete"
aria-label="Eliminar"
onClick={() => handleDeleteAvailability(alert)}
>
{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" />
@@ -89,8 +153,13 @@ function AlertsView({ onBack }) {
</button>
</div>
</div>
))}
);
})
)}
</div>
</section>
</>
)}
</main>
</div>
);
+122 -10
View File
@@ -3,13 +3,113 @@
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
overflow: hidden;
padding: 1rem var(--margin-main);
min-height: 100%;
max-width: 100%;
width: 100%;
padding: 1rem;
gap: 1.5rem;
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 {
display: flex;
flex-direction: column;
@@ -19,7 +119,7 @@
}
.home-logo-wrapper {
display: flex;
display: contents;
flex-direction: row;
align-items: center;
justify-content: center;
@@ -87,15 +187,27 @@
}
.home-card--search {
background: var(--primary);
color: var(--on-primary);
box-shadow: 0 4px 16px rgba(0, 69, 13, 0.3);
/* Make suggestion/search buttons softer and more harmonious */
background: var(--primary-container);
color: var(--on-primary-container);
box-shadow: 0 6px 18px rgba(13, 43, 18, 0.06);
}
.home-card--scan {
background: var(--secondary);
color: var(--on-secondary);
box-shadow: 0 4px 16px rgba(43, 91, 181, 0.3);
/* Keep the scan button vivid and easy to find (intentionally prominent) */
background: #2b5bb5; /* vivid blue to stand out */
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 {
+35 -1
View File
@@ -300,7 +300,22 @@
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 {
flex-direction: column;
gap: 0.85rem;
@@ -310,4 +325,23 @@
width: 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 { getUserPosition } from '../utils/geo';
function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdminClick }) {
const [address, setAddress] = useState(currentUser?.address || '');
const [latitude, setLatitude] = useState(
currentUser?.latitude != null ? String(currentUser.latitude) : ''
@@ -158,6 +158,20 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
<polyline points="9 18 15 12 9 6" />
</svg>
</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 className="profile-location-section">
+61 -34
View File
@@ -1,10 +1,11 @@
import React, { useState, useCallback, useRef } from 'react';
import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning';
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';
const CIP_REGEX = /^[A-Z0-9]{16}$/i;
const CIP_REGEX = /^[A-Z0-9]{8,30}$/i;
function playBeep() {
try {
@@ -29,6 +30,12 @@ function ScannerView({ onClose, onSelectMedicine }) {
const rafRef = 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 [manualCip, setManualCip] = useState('');
const [prescriptions, setPrescriptions] = useState([]);
@@ -112,6 +119,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
async function handleWebScan() {
setErrorMsg('');
console.log('[Scanner] Iniciando escaneo web...');
try {
if (!navigator.mediaDevices?.getUserMedia) {
setErrorMsg('Cámara no disponible en este navegador.');
@@ -119,58 +127,77 @@ function ScannerView({ onClose, onSelectMedicine }) {
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({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
});
console.log('[Scanner] Stream de cámara obtenido');
streamRef.current = stream;
detectorRef.current = detector;
setPhase('scanning');
await new Promise((resolve) => {
requestAnimationFrame(() => {
const waitForVideo = () => {
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play().then(resolve).catch(resolve);
} else {
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 {
requestAnimationFrame(waitForVideo);
}
};
requestAnimationFrame(waitForVideo);
});
let found = false;
async function scanFrame() {
if (found || !videoRef.current || !detectorRef.current) return;
try {
const barcodes = await detectorRef.current.detect(videoRef.current);
if (barcodes.length > 0) {
const rawValue = barcodes[0].rawValue;
if (rawValue && CIP_REGEX.test(rawValue)) {
found = true;
console.log('[Scanner] Usando zxing local (@zxing/browser)...');
const hints = new Map();
hints.set(DecodeHintType.POSSIBLE_FORMATS, [
ZxingFormat.CODE_128,
ZxingFormat.CODE_39,
ZxingFormat.CODE_93,
ZxingFormat.PDF_417,
ZxingFormat.QR_CODE,
ZxingFormat.EAN_13,
ZxingFormat.EAN_8,
ZxingFormat.UPC_A,
ZxingFormat.UPC_E,
ZxingFormat.ITF,
]);
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(rawValue);
setScannedCip(cleaned);
setPhase('prescriptions');
fetchPrescriptions(rawValue);
return;
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) {
console.error('[Scanner] Error general:', err);
stopCamera();
if (err.name === 'NotAllowedError') {
setErrorMsg('Permiso de cámara denegado. Permite el acceso e intenta de nuevo.');
+67 -19
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 MedicineResults from '../components/MedicineResults';
import PharmacyList from '../components/PharmacyList';
@@ -13,11 +13,6 @@ const suggestions = [
{ name: 'Omeprazol', icon: 'emergency_home', color: 'tint' },
];
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 }) {
const [searchQuery, setSearchQuery] = useState('');
const [medicines, setMedicines] = useState([]);
@@ -29,6 +24,44 @@ function SearchView({ currentUser, onLoginRequest }) {
const [sortByDistance, setSortByDistance] = useState(false);
const [locating, setLocating] = useState(false);
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(() => {
const searchMedicines = async () => {
@@ -157,26 +190,37 @@ function SearchView({ currentUser, onLoginRequest }) {
</div>
</section>
{currentUser && recentSearches.length > 0 && (
<section className="recent-section">
<h2 className="section-title">Resultados Recientes</h2>
<div className="recent-list">
{recentResults.map((r, i) => (
<div key={i} className="recent-card">
{recentSearches.map((r) => (
<div
key={r.id}
className="recent-card"
onClick={() => setSelectedMedicine(r)}
style={{ cursor: 'pointer' }}
>
<div className="recent-card-top">
<div>
<h3 className="recent-name">{r.name}</h3>
<p className="recent-detail">{r.detail}</p>
<p className="recent-detail">
{r.dosage && `${r.dosage}`}
{r.dosage && r.form && ' \u2022 '}
{r.form}
</p>
</div>
<span className="recent-tag">{r.tag}</span>
{r.active_ingredient && (
<span className="recent-tag">{r.active_ingredient}</span>
)}
</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">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>{r.distance}</span>
</div>
<button className="recent-btn">
<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" />
@@ -187,13 +231,17 @@ function SearchView({ currentUser, onLoginRequest }) {
))}
</div>
</section>
)}
</>
)}
{searchQuery && !selectedMedicine && (
<MedicineResults
medicines={medicines}
onSelect={setSelectedMedicine}
onSelect={(m) => {
saveToRecent(m);
setSelectedMedicine(m);
}}
query={searchQuery}
currentUser={currentUser}
onLoginRequest={onLoginRequest}
+1 -1
View File
@@ -20,7 +20,7 @@ export default defineConfig({
name: 'FarmaClic',
short_name: 'FarmaClic',
description: 'Encuentra medicamentos en farmacias cercanas',
theme_color: '#00450d',
theme_color: '#7fbf8f',
background_color: '#f8fafb',
display: 'standalone',
start_url: '/',
+1 -1
View File
@@ -15,4 +15,4 @@ COPY . .
EXPOSE 8000
CMD ["python", "entrypoint.py"]
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--loop", "uvloop", "--http", "httptools"]
@@ -19,8 +19,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return await call_next(request)
path = request.url.path
exempt_prefixes = ("/docs", "/redoc", "/openapi", "/metrics", "/api/v1/system/health")
if any(path.startswith(p) for p in exempt_prefixes):
if path.startswith("/docs") or path.startswith("/redoc") or path.startswith("/openapi") or path.startswith("/metrics"):
return await call_next(request)
client_key = self._get_client_key(request)
@@ -73,9 +73,7 @@ async def test_api_key_read_access():
r2 = await c.get("/api/v1/medications", headers=api_h)
assert r2.status_code == 200
if r.json()["total"] > 0:
pharm_id = r.json()["items"][0]["id"]
r3 = await c.get(f"/api/v1/pharmacies/{pharm_id}/inventory", headers=api_h)
r3 = await c.get("/api/v1/inventory", headers=api_h)
assert r3.status_code == 200
print(" PASS: test_api_key_read_access")
@@ -75,8 +75,19 @@ async def test_rate_limit_exempt_paths():
async def test_rate_limit_per_client():
async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as c:
token = await get_admin_token(c)
h = bearer_headers(token)
r0 = await c.get("/api/v1/pharmacies", headers=h)
limit = int(r0.headers["x-ratelimit-limit"])
for _ in range(limit + 5):
r = await c.get("/api/v1/pharmacies", headers=h)
if r.status_code == 429:
break
r2 = await c.get("/api/v1/system/health")
assert r2.status_code == 200, "Health endpoint should work regardless of rate limit on other paths (it is exempt)"
assert r2.status_code == 200, "Health endpoint should work regardless of rate limit on other paths"
print(" PASS: test_rate_limit_per_client")