Merge branch 'turborepo_changes'
Build & Push Docker Images / test-backend (push) Successful in 1m47s
Build & Push Docker Images / test-frontend (push) Successful in 1m43s
Build & Push Docker Images / build-backend (push) Successful in 2m32s
Build & Push Docker Images / build-frontend (push) Failing after 20s
Build & Push Docker Images / deploy (push) Has been skipped

This commit was merged in pull request #11.
This commit is contained in:
Antoni Nuñez Romeu
2026-07-06 17:37:28 +02:00
372 changed files with 27193 additions and 3429 deletions
+46
View File
@@ -0,0 +1,46 @@
/**
* FarmaClic external pharmacy sources (OpenStreetMap, Google Places, open-data URLs).
* Used by the backend admin import; OSM does not require n8n.
*/
export { buildAddressFromOsmTags, osmElementToPharmacy } from './normalize.js';
export { fetchPharmaciesFromOsm } from './osm-overpass.js';
export { fetchPharmaciesFromOpenDataUrl } from './open-data.js';
import { fetchPharmaciesFromOsm } from './osm-overpass.js';
import { fetchPharmaciesFromOpenDataUrl } from './open-data.js';
/**
* @param {{
* source: 'osm' | 'openData',
* lat?: number|string,
* lon?: number|string,
* lng?: number|string,
* radio?: number|string,
* openDataUrl?: string,
* }} opts
*/
export async function fetchPharmaciesExternal(opts) {
const source = opts?.source;
if (source === 'openData') {
return fetchPharmaciesFromOpenDataUrl(opts.openDataUrl);
}
const lat = parseFloat(opts.lat);
const lon = parseFloat(opts.lon ?? opts.lng);
const radio = parseFloat(opts.radio) || 1500;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new Error('lat and lon are required for osm sources');
}
if (source === 'osm') {
return fetchPharmaciesFromOsm({
lat,
lon,
radiusMeters: radio,
});
}
throw new Error('source must be "osm", or "openData"');
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Shared helpers: OSM tags → single-line address, element → pharmacy row.
*/
import { parseOsmOpeningHours } from './opening-hours-osm.js';
export function buildAddressFromOsmTags(tags) {
if (!tags || typeof tags !== 'object') return '';
if (tags['addr:full'] && String(tags['addr:full']).trim()) {
return String(tags['addr:full']).trim();
}
const street = [tags['addr:street'], tags['addr:housenumber']].filter(Boolean).join(' ').trim();
const cityLine = [tags['addr:postcode'], tags['addr:city'] || tags['addr:place'] || tags['addr:suburb']]
.filter(Boolean)
.join(' ')
.trim();
const parts = [street, cityLine].filter(Boolean);
if (parts.length) return parts.join(', ');
if (tags['addr:province'] && tags['addr:city']) {
return `${tags['addr:city']}, ${tags['addr:province']}`.trim();
}
return '';
}
/**
* @param {object} el - Overpass element (node | way | relation with optional center)
*/
export function osmElementToPharmacy(el) {
const tags = el.tags || {};
let lat;
let lon;
if (el.type === 'node' && el.lat != null && el.lon != null) {
lat = Number(el.lat);
lon = Number(el.lon);
} else if (el.center && el.center.lat != null && el.center.lon != null) {
lat = Number(el.center.lat);
lon = Number(el.center.lon);
}
const name = tags.name || tags.brand || tags.operator || null;
let address = buildAddressFromOsmTags(tags);
if (!address && tags['addr:country']) {
address = tags['addr:city'] || tags['addr:place'] || '';
}
if (!address && name) {
address = `OpenStreetMap (no address tags; id ${el.type}/${el.id})`;
}
const phone =
tags.phone || tags['contact:phone'] || tags['contact:mobile'] || tags['contact:whatsapp'] || null;
const opening_hours = parseOsmOpeningHours(tags.opening_hours);
return {
name,
address: address || null,
phone: phone ? String(phone).trim() : null,
latitude: Number.isFinite(lat) ? lat : null,
longitude: Number.isFinite(lon) ? lon : null,
opening_hours: opening_hours || null,
};
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Load pharmacy rows from a public open-data URL (JSON array, GeoJSON, or similar).
* No default URL — pass the dataset URL from the client (regional open-data portals).
*/
function rowsFromGeoJson(json) {
if (!json || json.type !== 'FeatureCollection' || !Array.isArray(json.features)) {
return [];
}
return json.features
.map((f) => {
const p = f.properties || {};
const name = p.name || p.nombre || p.denominacion || p.title;
const address =
p.address ||
p.direccion ||
p.domicilio ||
[p.calle, p.municipio, p.codigo_postal].filter(Boolean).join(', ');
let lat;
let lon;
if (f.geometry?.type === 'Point' && Array.isArray(f.geometry.coordinates)) {
lon = f.geometry.coordinates[0];
lat = f.geometry.coordinates[1];
}
return {
name: name ? String(name).trim() : null,
address: address ? String(address).trim() : null,
phone: p.phone || p.telefono || p.tel || null,
latitude: lat != null ? Number(lat) : null,
longitude: lon != null ? Number(lon) : null,
};
})
.filter((r) => r.name && r.address);
}
function rowsFromJsonArray(json) {
if (!Array.isArray(json)) return [];
return json
.map((row) => {
if (!row || typeof row !== 'object') return null;
const name = row.name || row.nombre || row.farmacia;
const address =
row.address ||
row.direccion ||
row.domicilio ||
row.full_address;
return {
name: name ? String(name).trim() : null,
address: address ? String(address).trim() : null,
phone: row.phone || row.telefono || null,
latitude:
row.latitude != null
? Number(row.latitude)
: row.lat != null
? Number(row.lat)
: null,
longitude:
row.longitude != null
? Number(row.longitude)
: row.lon != null
? Number(row.lon)
: row.lng != null
? Number(row.lng)
: null,
};
})
.filter((r) => r && r.name && r.address);
}
/**
* @param {string} openDataUrl - HTTPS URL returning JSON
* @returns {Promise<Array<{name,address,phone,latitude,longitude}>>}
*/
export async function fetchPharmaciesFromOpenDataUrl(openDataUrl) {
if (!openDataUrl || typeof openDataUrl !== 'string' || !openDataUrl.trim()) {
throw new Error('openData source requires a non-empty openDataUrl');
}
const u = openDataUrl.trim();
if (!/^https?:\/\//i.test(u)) {
throw new Error('openDataUrl must be an http(s) URL');
}
const res = await fetch(u, {
headers: {
Accept: 'application/json',
'User-Agent': 'FarmaClic/1.0 (open-data pharmacy import)',
},
});
const text = await res.text();
if (!res.ok) {
throw new Error(`Open data HTTP ${res.status}: ${text.slice(0, 120)}`);
}
let json;
try {
json = text ? JSON.parse(text) : null;
} catch {
throw new Error('Open data response is not JSON');
}
if (json && json.type === 'FeatureCollection') {
return rowsFromGeoJson(json);
}
if (Array.isArray(json)) {
return rowsFromJsonArray(json);
}
if (json && Array.isArray(json.data)) {
return rowsFromJsonArray(json.data);
}
if (json && Array.isArray(json.records)) {
return rowsFromJsonArray(json.records);
}
throw new Error(
'Unsupported open-data JSON shape (expected FeatureCollection, array, or { data: [] })'
);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Parse a subset of the OSM opening_hours format into FarmaClic's internal
* shape: { mon: ["09:00", "21:00"], ..., sun: null }.
*
* Supports the patterns commonly seen on amenity=pharmacy nodes:
* "24/7"
* "Mo-Fr 09:00-21:00"
* "Mo-Fr 09:00-21:00; Sa 09:00-14:00; Su closed"
* "Mo,We,Fr 09:00-14:00"
* "Mo-Fr 09:00-13:30,16:30-20:00" (split shifts: opens first, closes last)
*
* Public-holiday rules (PH/SH) and weeknumber/date filters are ignored.
* Returns null if no usable rule was found.
*
* @param {string} input
* @returns {{mon: string[]|null, tue: string[]|null, wed: string[]|null, thu: string[]|null, fri: string[]|null, sat: string[]|null, sun: string[]|null}|null}
*/
export function parseOsmOpeningHours(input) {
if (!input || typeof input !== 'string') return null;
const cleaned = input.trim().replace(/\s+/g, ' ');
if (!cleaned) return null;
const result = { mon: null, tue: null, wed: null, thu: null, fri: null, sat: null, sun: null };
if (/^24\s*\/\s*7$/i.test(cleaned)) {
for (const k of Object.keys(result)) result[k] = ['00:00', '24:00'];
return result;
}
const rules = cleaned.split(';').map(r => r.trim()).filter(Boolean);
let anySet = false;
for (const rule of rules) {
const ruleNoComment = rule.replace(/\([^)]*\)/g, '').trim();
if (!ruleNoComment) continue;
const lower = ruleNoComment.toLowerCase();
if (lower === 'closed' || lower === 'off') continue;
const tokens = ruleNoComment.split(/\s+/);
const dayPart = tokens.shift();
const timePart = tokens.join(' ').trim();
if (!dayPart) continue;
const days = expandDayPart(dayPart);
if (!days.length) continue;
if (!timePart || /^(off|closed)$/i.test(timePart)) {
for (const d of days) result[d] = null;
anySet = true;
continue;
}
const range = collapseTimeRanges(timePart);
if (!range) continue;
for (const d of days) result[d] = range;
anySet = true;
}
return anySet ? result : null;
}
const DAY_CODE_TO_KEY = { mo: 'mon', tu: 'tue', we: 'wed', th: 'thu', fr: 'fri', sa: 'sat', su: 'sun' };
const DAY_ORDER = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
function expandDayPart(dayPart) {
const out = new Set();
for (const segment of dayPart.split(',')) {
const seg = segment.trim();
if (!seg) continue;
if (/^(ph|sh)(-(ph|sh))?$/i.test(seg)) continue;
const rangeMatch = seg.match(/^([A-Za-z]{2})-([A-Za-z]{2})$/);
if (rangeMatch) {
const a = DAY_CODE_TO_KEY[rangeMatch[1].toLowerCase()];
const b = DAY_CODE_TO_KEY[rangeMatch[2].toLowerCase()];
if (!a || !b) continue;
const ai = DAY_ORDER.indexOf(a);
const bi = DAY_ORDER.indexOf(b);
if (ai === -1 || bi === -1) continue;
if (ai <= bi) {
for (let i = ai; i <= bi; i++) out.add(DAY_ORDER[i]);
} else {
for (let i = ai; i < DAY_ORDER.length; i++) out.add(DAY_ORDER[i]);
for (let i = 0; i <= bi; i++) out.add(DAY_ORDER[i]);
}
continue;
}
const single = DAY_CODE_TO_KEY[seg.toLowerCase()];
if (single) out.add(single);
}
return [...out];
}
function collapseTimeRanges(timePart) {
const matches = [...timePart.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
if (matches.length === 0) return null;
const first = matches[0];
const last = matches[matches.length - 1];
const open = `${first[1].padStart(2, '0')}:${first[2]}`;
const close = `${last[3].padStart(2, '0')}:${last[4]}`;
return [open, close];
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Pharmacies from OpenStreetMap via Overpass API (no API key).
* @see https://wiki.openstreetmap.org/wiki/Overpass_API
*/
import { osmElementToPharmacy } from './normalize.js';
const OVERPASS_ENDPOINTS = (
process.env.OVERPASS_API_URL ||
'https://overpass-api.de/api/interpreter,https://overpass.openstreetmap.fr/api/interpreter'
)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
function buildOverpassQuery(lat, lon, radiusM) {
// Nodes only (fast); ways/relations are rarer for pharmacies and heavy on public Overpass.
return `[out:json][timeout:25];
(
node["amenity"="pharmacy"](around:${radiusM},${lat},${lon});
node["healthcare"="pharmacy"](around:${radiusM},${lat},${lon});
);
out;`;
}
const FETCH_MS = 35000;
async function fetchOverpass(endpoint, body) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_MS);
try {
return await fetch(endpoint, {
method: 'POST',
signal: ctrl.signal,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'User-Agent': 'FarmaClic/1.0 (OSM pharmacy import; local admin)',
},
body,
});
} finally {
clearTimeout(timer);
}
}
/**
* @param {{ lat: number, lon: number, radiusMeters?: number }} opts
* @returns {Promise<Array<{name,address,phone,latitude,longitude}>>}
*/
export async function fetchPharmaciesFromOsm({ lat, lon, radiusMeters = 1500 }) {
const latN = Number(lat);
const lonN = Number(lon);
if (!Number.isFinite(latN) || !Number.isFinite(lonN)) {
throw new Error('fetchPharmaciesFromOsm: invalid lat or lon');
}
const r = Math.round(
Math.min(Math.max(Number(radiusMeters) || 1500, 50), 25000)
);
const body = `data=${encodeURIComponent(buildOverpassQuery(latN, lonN, r))}`;
let lastErr = null;
let json = null;
let text = '';
for (const endpoint of OVERPASS_ENDPOINTS) {
let res;
try {
res = await fetchOverpass(endpoint, body);
} catch (e) {
lastErr =
e.name === 'AbortError'
? new Error(`Overpass timeout (${FETCH_MS / 1000}s) at ${new URL(endpoint).host}`)
: e;
continue;
}
text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : {};
} catch {
lastErr = new Error(
`Overpass returned non-JSON (HTTP ${res.status}): ${(text || '').slice(0, 120)}`
);
continue;
}
if (!res.ok && (res.status === 504 || res.status === 502 || res.status === 429)) {
lastErr = new Error(
`Overpass busy (HTTP ${res.status}) at ${new URL(endpoint).host}`
);
continue;
}
if (!res.ok) {
const msg = parsed.remark || parsed.error || text.slice(0, 200);
throw new Error(`Overpass HTTP ${res.status}: ${msg}`);
}
json = parsed;
lastErr = null;
break;
}
if (lastErr && !json) {
throw lastErr;
}
if (!json || !Array.isArray(json.elements)) {
throw lastErr || new Error('Overpass: no usable response');
}
const elements = json.elements;
const seen = new Set();
const out = [];
for (const el of elements) {
const key = `${el.type}/${el.id}`;
if (seen.has(key)) continue;
seen.add(key);
const row = osmElementToPharmacy(el);
if (row.name && row.address) {
out.push(row);
}
}
return out;
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "farma-clic-api-sources",
"private": true,
"type": "module"
}
+17
View File
@@ -0,0 +1,17 @@
PORT=3001
SESSION_SECRET=change-me-in-production
CORS_ORIGIN=http://localhost:3000
FARMACIAS_WEBHOOK_URL=
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# PostgreSQL for user accounts (leave unset to fallback to SQLite — dev/test only)
PG_URL=postgresql://farmaclic:change-me@localhost:5432/farmaclic
PG_PASSWORD=change-me
# Web Push (VAPID). Generate with:
# node -e "import('web-push').then(w => console.log(w.default.generateVAPIDKeys()))"
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@example.com
+10
View File
@@ -0,0 +1,10 @@
FROM node:18-slim
WORKDIR /app
COPY apps/backend/package*.json ./
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN npm ci --omit=dev
COPY apps/backend/ .
COPY apps/API/ /API/
RUN mkdir -p /app/data
EXPOSE 3001
CMD ["node", "server.js"]
+144
View File
@@ -0,0 +1,144 @@
# 🔧 Solución Rápida - Error: no such column: medicine_id
## ❌ El Error
```
Error: SQLITE_ERROR: no such column: medicine_id
```
Este error ocurre porque la base de datos tiene la estructura antigua que usa `medicine_id`, pero el código actualizado ahora usa `medicine_nregistro`.
## ✅ Soluciones
### Opción 1: Reset Completo (Recomendado para desarrollo)
**Esto eliminará todos los datos actuales:**
```bash
cd backend
# Método 1: Usando el script
npm run reset-db
# Método 2: Manual
rm database.sqlite
node seed.js
node create-admin.js
```
### Opción 2: Migración (Mantiene farmacias, pierde vínculos medicamento-farmacia)
```bash
cd backend
node migrate.js
```
**Nota:** Esta opción mantiene las farmacias pero elimina las relaciones medicamento-farmacia porque ahora usan un esquema diferente (nregistro de CIMA en lugar de IDs locales).
### Opción 3: Manual con SQLite
Si quieres más control:
```bash
cd backend
sqlite3 database.sqlite
# Dentro de SQLite:
DROP TABLE IF EXISTS pharmacy_medicines;
DROP INDEX IF EXISTS idx_pharmacy_medicine;
CREATE TABLE pharmacy_medicines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL,
medicine_nregistro TEXT NOT NULL,
medicine_name TEXT,
price REAL,
stock INTEGER DEFAULT 0,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id),
UNIQUE(pharmacy_id, medicine_nregistro)
);
CREATE INDEX idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro);
.quit
```
## 🔍 Verificar la Estructura
Para verificar que la base de datos tiene la estructura correcta:
```bash
cd backend
sqlite3 database.sqlite "PRAGMA table_info(pharmacy_medicines);"
```
**Salida esperada:**
```
0|id|INTEGER|0||1
1|pharmacy_id|INTEGER|1||0
2|medicine_nregistro|TEXT|1||0
3|medicine_name|TEXT|0||0
4|price|REAL|0||0
5|stock|INTEGER|0|0|0
```
## 🚀 Después de la Corrección
1. **Verifica que Redis esté corriendo:**
```bash
redis-cli ping
# Debe responder: PONG
```
2. **Inicia el servidor:**
```bash
cd backend
npm start
```
3. **Vincula medicamentos en el Admin Panel:**
- Ve a http://localhost:3000
- Haz login en el Admin Panel
- Ve a la pestaña "Link Medicine"
- Busca medicamentos desde CIMA
- Vincúlalos a tus farmacias
## 📝 ¿Por qué cambió?
La aplicación ahora usa la **API oficial de CIMA** (Agencia Española de Medicamentos) en lugar de almacenar medicamentos localmente.
**Beneficios:**
- ✅ Datos siempre actualizados
- ✅ Más de 30,000 medicamentos disponibles
- ✅ Información oficial y verificada
- ✅ Menos mantenimiento de base de datos
**Estructura anterior:**
```
pharmacy_medicines
- medicine_id → ID local en tabla medicines
```
**Estructura nueva:**
```
pharmacy_medicines
- medicine_nregistro → Número de registro de CIMA
- medicine_name → Nombre cacheado para mostrar
```
## 💡 Preguntas Frecuentes
**P: ¿Perderé mis farmacias?**
R: No, las farmacias se mantienen. Solo necesitas re-vincular los medicamentos.
**P: ¿Perderé los vínculos medicamento-farmacia?**
R: Sí, porque ahora usan un sistema diferente (nregistros de CIMA). Tendrás que re-vincularlos usando el panel de admin.
**P: ¿Y si tengo muchos vínculos?**
R: La migración vale la pena por los beneficios a largo plazo. La re-vinculación es fácil con la búsqueda en tiempo real desde CIMA.
## 📚 Más Información
- Ver [MIGRATION.md](./MIGRATION.md) para guía completa de migración
- Ver [CHANGES.md](./CHANGES.md) para lista de todos los cambios
- Ver [README.md](./README.md) para documentación general
@@ -0,0 +1,87 @@
import { parseOsmOpeningHours } from '../../API/opening-hours-osm.js';
describe('parseOsmOpeningHours', () => {
test('returns null for empty / non-string input', () => {
expect(parseOsmOpeningHours('')).toBeNull();
expect(parseOsmOpeningHours(null)).toBeNull();
expect(parseOsmOpeningHours(undefined)).toBeNull();
expect(parseOsmOpeningHours(123)).toBeNull();
});
test('24/7 → every day 00:0024:00', () => {
expect(parseOsmOpeningHours('24/7')).toEqual({
mon: ['00:00', '24:00'],
tue: ['00:00', '24:00'],
wed: ['00:00', '24:00'],
thu: ['00:00', '24:00'],
fri: ['00:00', '24:00'],
sat: ['00:00', '24:00'],
sun: ['00:00', '24:00'],
});
});
test('Mo-Fr 09:00-21:00 → weekdays set, weekend null', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-21:00');
expect(result.mon).toEqual(['09:00', '21:00']);
expect(result.fri).toEqual(['09:00', '21:00']);
expect(result.sat).toBeNull();
expect(result.sun).toBeNull();
});
test('Multiple rules separated by semicolons', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-21:00; Sa 09:00-14:00; Su closed');
expect(result.mon).toEqual(['09:00', '21:00']);
expect(result.fri).toEqual(['09:00', '21:00']);
expect(result.sat).toEqual(['09:00', '14:00']);
expect(result.sun).toBeNull();
});
test('Comma-separated day list', () => {
const result = parseOsmOpeningHours('Mo,We,Fr 10:00-14:00');
expect(result.mon).toEqual(['10:00', '14:00']);
expect(result.tue).toBeNull();
expect(result.wed).toEqual(['10:00', '14:00']);
expect(result.thu).toBeNull();
expect(result.fri).toEqual(['10:00', '14:00']);
});
test('Split shifts collapsed to first-open / last-close', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-13:30,16:30-20:00');
expect(result.mon).toEqual(['09:00', '20:00']);
expect(result.fri).toEqual(['09:00', '20:00']);
});
test('Wrap-around day range Sa-Mo', () => {
const result = parseOsmOpeningHours('Sa-Mo 10:00-18:00');
expect(result.sat).toEqual(['10:00', '18:00']);
expect(result.sun).toEqual(['10:00', '18:00']);
expect(result.mon).toEqual(['10:00', '18:00']);
expect(result.tue).toBeNull();
});
test('Public-holiday rules are ignored', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-21:00; PH off');
expect(result.mon).toEqual(['09:00', '21:00']);
});
test('Parenthetical comments are stripped', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-14:00 (verano)');
expect(result.mon).toEqual(['09:00', '14:00']);
});
test('"off" applies null to those days', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-21:00; Sa off');
expect(result.sat).toBeNull();
expect(result.mon).toEqual(['09:00', '21:00']);
});
test('Returns null when nothing parses', () => {
expect(parseOsmOpeningHours('see website')).toBeNull();
expect(parseOsmOpeningHours('?')).toBeNull();
});
test('Single-digit hours get zero-padded', () => {
const result = parseOsmOpeningHours('Mo 9:00-18:00');
expect(result.mon).toEqual(['09:00', '18:00']);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { jest } from '@jest/globals'
jest.unstable_mockModule('../cima-service.js', () => ({
searchMedicines: jest.fn(async () => []),
getMedicineDetails: jest.fn(async () => null),
}))
jest.unstable_mockModule('../farmacias-webhook-import.js', () => ({
runFarmaciaWebhookImport: jest.fn(async () => ({})),
DEFAULT_FARMACIAS_WEBHOOK: '',
importPharmaciesFromRows: jest.fn(async () => ({})),
}))
jest.unstable_mockModule('../../API/index.js', () => ({
fetchPharmaciesExternal: jest.fn(async () => []),
}))
process.env.DATABASE_PATH = ':memory:'
process.env.NODE_ENV = 'test'
const { default: supertest } = await import('supertest')
const { app, initDatabase, db } = await import('../server.js')
const { default: bcrypt } = await import('bcrypt')
beforeAll(async () => {
await initDatabase()
const hash = await bcrypt.hash('testpass', 10)
await new Promise((resolve, reject) => {
db.run(
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)',
['testadmin', hash],
(err) => (err ? reject(err) : resolve())
)
})
})
describe('Medicine search', () => {
test('GET /api/medicines/search with empty q returns []', async () => {
const res = await supertest(app).get('/api/medicines/search?q=')
expect(res.status).toBe(200)
expect(res.body).toEqual([])
})
test('GET /api/medicines/search with short q returns array', async () => {
const res = await supertest(app).get('/api/medicines/search?q=a')
expect(res.status).toBe(200)
expect(Array.isArray(res.body)).toBe(true)
})
})
describe('Authentication', () => {
test('POST /api/auth/login with wrong creds returns 401', async () => {
const res = await supertest(app)
.post('/api/auth/login')
.send({ username: 'nobody', password: 'wrong' })
expect(res.status).toBe(401)
})
})
describe('Admin routes', () => {
test('GET /api/admin/medicines without auth returns 401', async () => {
const res = await supertest(app).get('/api/admin/medicines')
expect(res.status).toBe(401)
})
test('POST /api/admin/pharmacies with valid auth returns 201', async () => {
const agent = supertest.agent(app)
const login = await agent
.post('/api/auth/login')
.send({ username: 'testadmin', password: 'testpass' })
expect(login.status).toBe(200)
const res = await agent.post('/api/admin/pharmacies').send({
name: 'Test Pharmacy',
address: 'Test Street 1',
})
expect(res.status).toBe(201)
expect(res.body).toMatchObject({ name: 'Test Pharmacy', address: 'Test Street 1' })
})
})
+186
View File
@@ -0,0 +1,186 @@
import axios from 'axios';
import redisClient from './redis-client.js';
const CIMA_API_BASE_URL = 'https://cima.aemps.es/cima/rest';
const CACHE_TTL = 3600; // 1 hora en segundos
/**
* CIMA's nombre filter is prefix-oriented; narrow to rows that contain every
* search term in the commercial name or active ingredient (full-word style).
*/
function filterMedicinesByFullQuery(medicines, searchTerm) {
const terms = searchTerm
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (terms.length === 0) return medicines;
return medicines.filter((m) => {
const hay = `${m.name || ''} ${m.active_ingredient || ''}`.toLowerCase();
return terms.every((term) => hay.includes(term));
});
}
/**
* Busca medicamentos en la API de CIMA con caché de Redis
* @param {string} query - Término de búsqueda
* @returns {Promise<Array>} - Lista de medicamentos encontrados
*/
export async function searchMedicines(query) {
if (!query || query.trim().length < 2) {
return [];
}
const searchTerm = query.trim().toLowerCase();
const cacheKey = `medicines:search:v2:${searchTerm}`;
try {
// Intentar obtener del caché
const cachedData = await redisClient.get(cacheKey);
if (cachedData) {
console.log(`📦 Cache hit for: ${searchTerm}`);
return JSON.parse(cachedData);
}
// Si no está en caché, consultar la API de CIMA
console.log(`🌐 Fetching from CIMA API: ${searchTerm}`);
const response = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, {
params: {
nombre: searchTerm
},
timeout: 5000
});
if (response.data && response.data.resultados) {
// Transformar los datos de CIMA a nuestro formato
const medicines = response.data.resultados.map(med => ({
id: med.nregistro,
nregistro: med.nregistro,
name: med.nombre,
active_ingredient: med.vtm?.nombre || null,
dosage: med.dosis || null,
form: med.formaFarmaceutica?.nombre || null,
formSimplified: med.formaFarmaceuticaSimplificada?.nombre || null,
laboratory: med.labtitular,
prescription: med.cpresc,
commercialized: med.comerc,
generic: med.generico,
photos: med.fotos || [],
docs: med.docs || []
}));
const filtered = filterMedicinesByFullQuery(medicines, searchTerm);
// Guardar en caché
await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(filtered));
console.log(`✅ Cached ${filtered.length} medicines for: ${searchTerm}`);
return filtered;
}
return [];
} catch (error) {
console.error('Error searching medicines from CIMA:', error.message);
// Si falla, intentar devolver datos cacheados aunque hayan expirado
try {
const staleData = await redisClient.get(cacheKey);
if (staleData) {
console.log('⚠️ Returning stale cache data due to API error');
return JSON.parse(staleData);
}
} catch (cacheError) {
console.error('Cache fallback also failed:', cacheError);
}
return [];
}
}
/**
* Obtiene detalles de un medicamento específico por su número de registro
* @param {string} nregistro - Número de registro del medicamento
* @returns {Promise<Object|null>} - Datos del medicamento
*/
export async function getMedicineDetails(nregistro) {
const cacheKey = `medicine:${nregistro}`;
try {
// Intentar obtener del caché
const cachedData = await redisClient.get(cacheKey);
if (cachedData) {
console.log(`📦 Cache hit for medicine: ${nregistro}`);
return JSON.parse(cachedData);
}
// Consultar la API de CIMA
console.log(`🌐 Fetching medicine details from CIMA: ${nregistro}`);
const response = await axios.get(`${CIMA_API_BASE_URL}/medicamento/${nregistro}`, {
timeout: 5000
});
if (response.data) {
const med = response.data;
const medicineDetails = {
id: med.nregistro,
nregistro: med.nregistro,
name: med.nombre,
active_ingredient: med.principiosActivos?.[0]?.nombre || med.vtm?.nombre || null,
dosage: med.dosis || null,
form: med.formaFarmaceutica?.nombre || null,
formSimplified: med.formaFarmaceuticaSimplificada?.nombre || null,
laboratory: med.labtitular,
prescription: med.cpresc,
commercialized: med.comerc,
generic: med.generico,
photos: med.fotos || [],
docs: med.docs || [],
presentations: med.presentaciones || []
};
// Guardar en caché (TTL más largo para detalles específicos)
await redisClient.setEx(cacheKey, CACHE_TTL * 24, JSON.stringify(medicineDetails));
return medicineDetails;
}
return null;
} catch (error) {
console.error(`Error fetching medicine ${nregistro} from CIMA:`, error.message);
// Intentar devolver datos cacheados aunque hayan expirado
try {
const staleData = await redisClient.get(cacheKey);
if (staleData) {
console.log('⚠️ Returning stale cache data due to API error');
return JSON.parse(staleData);
}
} catch (cacheError) {
console.error('Cache fallback also failed:', cacheError);
}
return null;
}
}
/**
* Limpia el caché de búsquedas (útil para testing o mantenimiento)
* @param {string} pattern - Patrón de claves a eliminar (ej: 'medicines:search:*')
* @returns {Promise<number>} - Número de claves eliminadas
*/
export async function clearCache(pattern = 'medicines:*') {
try {
const keys = await redisClient.keys(pattern);
if (keys.length > 0) {
await redisClient.del(keys);
console.log(`🗑️ Cleared ${keys.length} cache entries`);
return keys.length;
}
return 0;
} catch (error) {
console.error('Error clearing cache:', error);
return 0;
}
}
+97
View File
@@ -0,0 +1,97 @@
import sqlite3 from 'sqlite3';
import { promisify } from 'util';
import bcrypt from 'bcrypt';
import path from 'path';
import { fileURLToPath } from 'url';
import pg from 'pg';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PG_URL = process.env.PG_URL;
async function createAdmin() {
const username = process.env.ADMIN_USERNAME || 'admin';
const password = process.env.ADMIN_PASSWORD || 'admin123';
const passwordHash = await bcrypt.hash(password, 10);
if (PG_URL) {
const { Pool } = pg;
const pool = new Pool({ connectionString: PG_URL });
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
address TEXT,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
const existing = await pool.query('SELECT id FROM users WHERE username = $1', [username]);
if (existing.rows.length > 0) {
console.log(`Admin user '${username}' already exists.`);
console.log('To reset, delete the user first and re-run.');
await pool.end();
return;
}
await pool.query(
'INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, 1)',
[username, passwordHash]
);
console.log(`Admin user '${username}' created in PostgreSQL.`);
} finally {
await pool.end();
}
} else {
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'database.sqlite');
const db = new sqlite3.Database(dbPath);
const dbRun = (sql, params = []) =>
new Promise((resolve, reject) =>
db.run(sql, params, function (err) { err ? reject(err) : resolve({ lastID: this.lastID }); })
);
const dbGet = promisify(db.get.bind(db));
try {
await dbRun(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {}
const existing = await dbGet('SELECT id FROM users WHERE username = ?', [username]);
if (existing) {
console.log(`Admin user '${username}' already exists.`);
console.log('To reset, delete the user first and re-run.');
db.close();
return;
}
await dbRun(
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)',
[username, passwordHash]
);
console.log(`Admin user '${username}' created in SQLite.`);
} finally {
db.close();
}
}
console.log(`Username: ${username}`);
console.log(`Password: ${password}`);
console.log('\nIMPORTANT: Change the default password after first login!');
}
createAdmin().catch((err) => {
console.error('Error creating admin user:', err);
process.exit(1);
});
+308
View File
@@ -0,0 +1,308 @@
/**
* Fetch pharmacy lists from an n8n (or any) HTTP webhook and map into FarmaClic rows.
* Default URL: FARMACIAS_WEBHOOK_URL env or the project webhook.
*/
import { parseOsmOpeningHours } from '../API/opening-hours-osm.js';
export const DEFAULT_FARMACIAS_WEBHOOK =
process.env.FARMACIAS_WEBHOOK_URL ||
'https://n8n.hacecalor.net/webhook/farmacias';
/**
* Append region query params, e.g. GET /webhook/farmacias?lat=41.5631&lon=2.0038&radio=1500
* @param {string} baseUrl - Absolute webhook URL (may already include other query params)
* @param {{ lat?: number|string, lon?: number|string, lng?: number|string, radio?: number|string }} region
*/
export function buildFarmaciasWebhookUrl(baseUrl, region = {}) {
const u = new URL(baseUrl);
const lat = region.lat;
const lon = region.lon ?? region.lng;
const radio = region.radio;
if (lat !== undefined && lat !== null && String(lat).trim() !== '') {
u.searchParams.set('lat', String(lat).trim());
}
if (lon !== undefined && lon !== null && String(lon).trim() !== '') {
u.searchParams.set('lon', String(lon).trim());
}
if (radio !== undefined && radio !== null && String(radio).trim() !== '') {
u.searchParams.set('radio', String(radio).trim());
}
return u.toString();
}
function pick(obj, keys) {
if (!obj || typeof obj !== 'object') return null;
for (const k of keys) {
if (Object.prototype.hasOwnProperty.call(obj, k)) {
const v = obj[k];
if (v !== undefined && v !== null && String(v).trim() !== '') {
return String(v).trim();
}
}
}
return null;
}
function toNumber(v) {
if (v === undefined || v === null || v === '') return null;
const n = typeof v === 'number' ? v : parseFloat(String(v).replace(',', '.'));
return Number.isFinite(n) ? n : null;
}
/**
* Normalize one raw record (Spanish / English field names, GeoJSON-ish).
*/
export function normalizePharmacyRecord(raw) {
if (!raw || typeof raw !== 'object') return null;
if (raw.json != null && typeof raw.json === 'object' && !Array.isArray(raw.json)) {
return normalizePharmacyRecord(raw.json);
}
let name = pick(raw, [
'name',
'nombre',
'farmacia',
'titular',
'denominacion',
'denominación',
'razon_social',
'razón_social',
'title',
]);
let address = pick(raw, [
'address',
'direccion',
'dirección',
'domicilio',
'ubicacion',
'ubicación',
'calle',
'full_address',
'direccion_completa',
]);
const phone = pick(raw, [
'phone',
'telefono',
'teléfono',
'tel',
'telephone',
'movil',
'móvil',
]);
let latitude = toNumber(raw.latitude ?? raw.latitud ?? raw.lat ?? raw.y);
let longitude = toNumber(raw.longitude ?? raw.longitud ?? raw.lng ?? raw.lon ?? raw.x);
const coords = raw.geometry?.coordinates;
if (Array.isArray(coords) && coords.length >= 2) {
if (longitude == null) longitude = toNumber(coords[0]);
if (latitude == null) latitude = toNumber(coords[1]);
}
if (raw.location && typeof raw.location === 'object') {
if (latitude == null) latitude = toNumber(raw.location.lat ?? raw.location.latitude);
if (longitude == null) longitude = toNumber(raw.location.lng ?? raw.location.lon ?? raw.location.longitude);
}
if (!name && pick(raw, ['properties'])) {
return normalizePharmacyRecord(raw.properties);
}
if (!address && name) {
const parts = [pick(raw, ['localidad', 'city', 'municipio']), pick(raw, ['cp', 'codigo_postal', 'postal_code'])]
.filter(Boolean)
.join(', ');
if (parts) address = parts;
}
return {
name: name || null,
address: address || null,
phone: phone || null,
latitude,
longitude,
opening_hours: extractOpeningHours(raw),
};
}
function extractOpeningHours(raw) {
if (!raw || typeof raw !== 'object') return null;
const direct = raw.opening_hours;
if (direct && typeof direct === 'object' && !Array.isArray(direct)) {
return direct;
}
const candidates = [
direct,
raw.openingHours,
raw.horario,
raw.hours,
raw.tags?.opening_hours,
raw.properties?.opening_hours,
];
for (const c of candidates) {
if (typeof c === 'string' && c.trim()) {
const parsed = parseOsmOpeningHours(c);
if (parsed) return parsed;
}
}
return null;
}
/** n8n often returns [{ json: { ... } }, ...] */
function unwrapN8nItemArray(arr) {
if (!Array.isArray(arr) || arr.length === 0) return arr || [];
const first = arr[0];
if (
first &&
typeof first === 'object' &&
first.json != null &&
typeof first.json === 'object' &&
!Array.isArray(first.json)
) {
return arr.map((x) => x.json);
}
return arr;
}
export function extractPharmacyRows(payload) {
if (payload == null) return [];
let list = [];
if (Array.isArray(payload)) list = payload;
else if (typeof payload === 'object') {
const candidates = [
payload.farmacias,
payload.data,
payload.results,
payload.items,
payload.rows,
payload.records,
payload.pharmacies,
payload.body,
payload.output,
];
for (const c of candidates) {
if (Array.isArray(c)) {
list = c;
break;
}
}
if (list.length === 0 && Array.isArray(payload.json)) list = payload.json;
}
return unwrapN8nItemArray(list);
}
export async function fetchWebhookJson(url, fetchOptions = {}) {
const res = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json', ...fetchOptions.headers },
...fetchOptions,
});
const text = await res.text();
let json;
try {
json = text ? JSON.parse(text) : null;
} catch {
throw new Error(
`Webhook returned non-JSON (HTTP ${res.status}): ${text.slice(0, 300)}`
);
}
if (!res.ok) {
const hint = json?.message || JSON.stringify(json);
throw new Error(`Webhook HTTP ${res.status}: ${hint}`);
}
return json;
}
/**
* @param {Function} dbGet - (sql, params) => Promise<row|undefined>
* @param {Function} dbRun - (sql, params) => Promise<{lastID, changes}>
* @param {object[]} rows - raw webhook items
*/
/** Insert normalized pharmacy rows; exported for OSM/Google/open-data importers */
export async function importPharmaciesFromRows(dbGet, dbRun, rows) {
let inserted = 0;
let skipped = 0;
let invalid = 0;
const errors = [];
for (let i = 0; i < rows.length; i++) {
const normalized = normalizePharmacyRecord(rows[i]);
if (!normalized?.name || !normalized?.address) {
invalid++;
continue;
}
const { name, address, phone, latitude, longitude, opening_hours } = normalized;
try {
const existing = await dbGet(
'SELECT id FROM pharmacies WHERE name = ? AND address = ?',
[name, address]
);
if (existing) {
skipped++;
continue;
}
const openingHoursValue = opening_hours ? JSON.stringify(opening_hours) : null;
await dbRun(
'INSERT INTO pharmacies (name, address, phone, latitude, longitude, opening_hours) VALUES (?, ?, ?, ?, ?, ?)',
[name, address, phone || null, latitude, longitude, openingHoursValue]
);
inserted++;
} catch (err) {
errors.push({ index: i, message: err.message });
}
}
return { inserted, skipped, invalid, errors };
}
/**
* Full flow: GET webhook → parse rows → insert into DB.
* @param {string} [url] - Webhook base URL
* @param {{ lat?: number|string, lon?: number|string, lng?: number|string, radio?: number|string } | null} [region] - Optional; adds ?lat=&lon=&radio= (meters)
*/
export async function runFarmaciaWebhookImport(
dbGet,
dbRun,
url = DEFAULT_FARMACIAS_WEBHOOK,
region = null
) {
const finalUrl =
region && (region.lat != null || region.lon != null || region.lng != null || region.radio != null)
? buildFarmaciasWebhookUrl(url, region)
: url;
const json = await fetchWebhookJson(finalUrl);
const rows = extractPharmacyRows(json);
if (rows.length === 0) {
const keys = json && typeof json === 'object' ? Object.keys(json).join(', ') : typeof json;
const err = new Error(
`No pharmacy list found in webhook JSON (top-level keys: ${keys}). ` +
`Fix the n8n workflow so the last node returns an array or { data: [...] }.`
);
err.details = json;
throw err;
}
const stats = await importPharmaciesFromRows(dbGet, dbRun, rows);
const out = {
...stats,
totalReceived: rows.length,
webhookUrl: finalUrl,
};
if (region && (region.lat != null || region.lon != null || region.lng != null || region.radio != null)) {
out.region = {
lat: region.lat ?? null,
lon: region.lon ?? region.lng ?? null,
radio: region.radio ?? null,
};
}
return out;
}
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* CLI: pull pharmacies from webhook and insert into database.sqlite
*
* npm run import-farmacias
* FARMACIAS_WEBHOOK_URL=https://... npm run import-farmacias
*
* Region (adds ?lat=&lon=&radio= in metres), e.g. your city:
* node import-farmacias.js --lat 41.5631 --lon 2.0038 --radio 1500
* node import-farmacias.js "https://n8n.example/webhook/farmacias" --lat 41.5631 --lon 2.0038 --radio 1500
*
* Env defaults for region: FARMACIAS_IMPORT_LAT, FARMACIAS_IMPORT_LON, FARMACIAS_IMPORT_RADIO
*/
import sqlite3 from 'sqlite3';
import { promisify } from 'util';
import path from 'path';
import { fileURLToPath } from 'url';
import {
runFarmaciaWebhookImport,
DEFAULT_FARMACIAS_WEBHOOK,
} from './farmacias-webhook-import.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dbPath = path.join(__dirname, 'database.sqlite');
const db = new sqlite3.Database(dbPath);
function dbRun(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
const dbGet = promisify(db.get.bind(db));
function parseCli(argv) {
const region = {};
const positional = [];
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--lat' && argv[i + 1] != null) {
region.lat = argv[++i];
continue;
}
if ((a === '--lon' || a === '--lng') && argv[i + 1] != null) {
region.lon = argv[++i];
continue;
}
if (a === '--radio' && argv[i + 1] != null) {
region.radio = argv[++i];
continue;
}
if (a.startsWith('--')) {
console.warn('Unknown flag:', a);
continue;
}
positional.push(a);
}
if (process.env.FARMACIAS_IMPORT_LAT && region.lat == null) region.lat = process.env.FARMACIAS_IMPORT_LAT;
if (process.env.FARMACIAS_IMPORT_LON && region.lon == null) region.lon = process.env.FARMACIAS_IMPORT_LON;
if (process.env.FARMACIAS_IMPORT_RADIO && region.radio == null) {
region.radio = process.env.FARMACIAS_IMPORT_RADIO;
}
const url = positional[0] || DEFAULT_FARMACIAS_WEBHOOK;
const hasRegion =
region.lat != null || region.lon != null || region.radio != null;
return { url, region: hasRegion ? region : null };
}
async function main() {
const { url, region } = parseCli(process.argv);
console.log('Fetching pharmacies from:', url);
if (region) console.log('Region query:', region);
try {
const result = await runFarmaciaWebhookImport(dbGet, dbRun, url, region);
console.log('Done.');
console.log(' Total rows in response:', result.totalReceived);
console.log(' Inserted:', result.inserted);
console.log(' Skipped (duplicate name+address):', result.skipped);
console.log(' Invalid (missing name or address):', result.invalid);
if (result.errors.length) {
console.log(' Row errors:', result.errors.length);
console.log(result.errors.slice(0, 5));
}
} catch (e) {
console.error('Import failed:', e.message);
if (e.message.includes('Unused Respond to Webhook')) {
console.error(
'\n Hint: In n8n, connect the Webhook to a single "Respond to Webhook" node, or remove unused ones.'
);
}
process.exitCode = 1;
} finally {
db.close();
}
}
main();
+6
View File
@@ -0,0 +1,6 @@
export default {
testEnvironment: 'node',
transform: {},
moduleFileExtensions: ['js', 'json'],
testMatch: ['**/__tests__/**/*.test.js'],
}
+127
View File
@@ -0,0 +1,127 @@
import sqlite3 from 'sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dbPath = path.join(__dirname, 'database.sqlite');
const db = new sqlite3.Database(dbPath);
console.log('🔄 Starting database migration...');
// Promisify database operations
function dbRun(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
function dbAll(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
async function migrate() {
try {
// Check if old medicines table exists
const tables = await dbAll(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='medicines'
`);
if (tables.length > 0) {
console.log('📋 Found old medicines table');
// Check if we need to migrate pharmacy_medicines
const columns = await dbAll(`PRAGMA table_info(pharmacy_medicines)`);
const hasMedicineId = columns.some(col => col.name === 'medicine_id');
const hasNregistro = columns.some(col => col.name === 'medicine_nregistro');
if (hasMedicineId && !hasNregistro) {
console.log('🔄 Migrating pharmacy_medicines table...');
// Create new table with updated schema
await dbRun(`
CREATE TABLE pharmacy_medicines_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL,
medicine_nregistro TEXT NOT NULL,
medicine_name TEXT,
price REAL,
stock INTEGER DEFAULT 0,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id),
UNIQUE(pharmacy_id, medicine_nregistro)
)
`);
console.log('✅ Created new pharmacy_medicines table');
// Copy data if any exists (though it will be invalid without nregistro)
const oldData = await dbAll('SELECT * FROM pharmacy_medicines');
console.log(`📦 Found ${oldData.length} old pharmacy-medicine relationships`);
if (oldData.length > 0) {
console.log('⚠️ Warning: Old medicine relationships will be lost.');
console.log(' You will need to re-link medicines using the CIMA database.');
}
// Drop old table
await dbRun('DROP TABLE pharmacy_medicines');
// Rename new table
await dbRun('ALTER TABLE pharmacy_medicines_new RENAME TO pharmacy_medicines');
console.log('✅ Migrated pharmacy_medicines table');
} else if (hasNregistro) {
console.log('✅ pharmacy_medicines table already migrated');
}
// We can keep the old medicines table for reference, or drop it
console.log('️ Old medicines table can be kept for reference or deleted manually');
console.log(' To delete: sqlite3 database.sqlite "DROP TABLE IF EXISTS medicines;"');
} else {
console.log('✅ No old medicines table found - creating new schema');
// Create pharmacy_medicines table with new schema
await dbRun(`
CREATE TABLE IF NOT EXISTS pharmacy_medicines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL,
medicine_nregistro TEXT NOT NULL,
medicine_name TEXT,
price REAL,
stock INTEGER DEFAULT 0,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id),
UNIQUE(pharmacy_id, medicine_nregistro)
)
`);
console.log('✅ Created pharmacy_medicines table');
}
console.log('');
console.log('✨ Migration completed successfully!');
console.log('');
console.log('Next steps:');
console.log('1. Install Redis: brew install redis (macOS) or apt-get install redis-server (Linux)');
console.log('2. Start Redis: redis-server');
console.log('3. Install dependencies: npm install');
console.log('4. Start the server: npm start');
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
} finally {
db.close();
}
}
migrate();
+9298
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "farma-clic-backend",
"version": "1.0.0",
"description": "Backend API for FarmaClic",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node --env-file-if-exists=.env server.js",
"dev": "node --env-file-if-exists=.env --watch server.js",
"seed": "node seed.js",
"create-admin": "node create-admin.js",
"migrate": "node migrate.js",
"reset-db": "bash reset-db.sh",
"import-farmacias": "node import-farmacias.js",
"test": "NODE_OPTIONS='--experimental-vm-modules' npx jest --forceExit"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.52.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.55.0",
"@opentelemetry/instrumentation-pino": "^0.45.0",
"@opentelemetry/resources": "^1.28.0",
"@opentelemetry/sdk-logs": "^0.55.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/sdk-trace-base": "^1.28.0",
"@opentelemetry/semantic-conventions": "^1.28.0",
"axios": "^1.6.0",
"bcrypt": "^5.1.1",
"connect-pg-simple": "^10.0.0",
"connect-sqlite3": "^0.9.16",
"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": {
"jest": "^29.7.0",
"supertest": "^7.2.2"
}
}
View File
+25
View File
@@ -0,0 +1,25 @@
import { createClient } from 'redis';
// Create Redis client
const redisClient = createClient({
socket: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379
},
password: process.env.REDIS_PASSWORD || undefined
});
// Error handler
redisClient.on('error', (err) => {
console.error('Redis Client Error:', err);
});
// Connection handler
redisClient.on('connect', () => {
console.log('✅ Connected to Redis');
});
// Connect to Redis
await redisClient.connect();
export default redisClient;
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
echo "🔄 FarmaClic - Quick Database Reset"
echo "===================================="
echo ""
echo "Este script eliminará la base de datos actual y creará una nueva."
echo "⚠️ ADVERTENCIA: Todos los datos actuales se perderán."
echo ""
read -p "¿Continuar? (s/n): " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Ss]$ ]]
then
echo "Operación cancelada."
exit 1
fi
echo ""
echo "1️⃣ Eliminando base de datos antigua..."
rm -f database.sqlite
echo "2️⃣ Creando nueva base de datos con estructura actualizada..."
node seed.js
echo "3️⃣ Creando usuario administrador..."
node create-admin.js
echo ""
echo "✅ ¡Listo! Base de datos reiniciada con éxito."
echo ""
echo "Próximos pasos:"
echo "1. Asegúrate de que Redis esté corriendo: redis-server"
echo "2. Inicia el servidor: npm start"
echo ""
+171
View File
@@ -0,0 +1,171 @@
import sqlite3 from 'sqlite3';
import { promisify } from 'util';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dbPath = path.join(__dirname, 'database.sqlite');
const db = new sqlite3.Database(dbPath);
// Custom wrapper to get lastID from db.run
function dbRun(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
const dbGet = promisify(db.get.bind(db));
// Initialize database tables
async function initDatabase() {
try {
// Create pharmacies table
await dbRun(`
CREATE TABLE IF NOT EXISTS pharmacies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address TEXT NOT NULL,
phone TEXT,
latitude REAL,
longitude REAL
)
`);
// Create medicines table
await dbRun(`
CREATE TABLE IF NOT EXISTS medicines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
active_ingredient TEXT,
dosage TEXT,
form TEXT
)
`);
// Create junction table for pharmacy-medicine relationships
// Ahora usa nregistro (número de registro de CIMA) en lugar de medicine_id local
await dbRun(`
CREATE TABLE IF NOT EXISTS pharmacy_medicines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL,
medicine_nregistro TEXT NOT NULL,
medicine_name TEXT,
price REAL,
stock INTEGER DEFAULT 0,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id),
UNIQUE(pharmacy_id, medicine_nregistro)
)
`);
// Create indexes for better search performance
await dbRun(`CREATE INDEX IF NOT EXISTS idx_medicine_name ON medicines(name)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`);
console.log('Database tables initialized');
} catch (error) {
console.error('Error initializing database:', error);
throw error;
}
}
// Sample data
const pharmacies = [
{ name: 'Farmacia Central', address: 'Av. Principal 123, Ciudad', phone: '+34 123 456 789', lat: 40.4168, lng: -3.7038 },
{ name: 'Farmacia San José', address: 'Calle Mayor 45, Ciudad', phone: '+34 987 654 321', lat: 40.4178, lng: -3.7048 },
{ name: 'Farmacia del Sol', address: 'Plaza del Sol 12, Ciudad', phone: '+34 555 123 456', lat: 40.4158, lng: -3.7028 },
{ name: 'Farmacia Salud', address: 'Calle Salud 78, Ciudad', phone: '+34 666 789 012', lat: 40.4188, lng: -3.7058 },
{ name: 'Farmacia 24h', address: 'Av. Libertad 234, Ciudad', phone: '+34 777 345 678', lat: 40.4148, lng: -3.7018 },
];
const medicines = [
{ name: 'Paracetamol 500mg', active_ingredient: 'Paracetamol', dosage: '500mg', form: 'Tabletas' },
{ name: 'Ibuprofeno 600mg', active_ingredient: 'Ibuprofeno', dosage: '600mg', form: 'Tabletas' },
{ name: 'Aspirina 100mg', active_ingredient: 'Ácido Acetilsalicílico', dosage: '100mg', form: 'Tabletas' },
{ name: 'Amoxicilina 500mg', active_ingredient: 'Amoxicilina', dosage: '500mg', form: 'Cápsulas' },
{ name: 'Omeprazol 20mg', active_ingredient: 'Omeprazol', dosage: '20mg', form: 'Cápsulas' },
{ name: 'Loratadina 10mg', active_ingredient: 'Loratadina', dosage: '10mg', form: 'Tabletas' },
{ name: 'Diclofenaco 50mg', active_ingredient: 'Diclofenaco', dosage: '50mg', form: 'Tabletas' },
{ name: 'Metformina 850mg', active_ingredient: 'Metformina', dosage: '850mg', form: 'Tabletas' },
{ name: 'Atorvastatina 20mg', active_ingredient: 'Atorvastatina', dosage: '20mg', form: 'Tabletas' },
{ name: 'Losartán 50mg', active_ingredient: 'Losartán', dosage: '50mg', form: 'Tabletas' },
];
async function seedDatabase() {
try {
console.log('Starting database seeding...');
// Initialize database tables first
await initDatabase();
// Clear existing data
await dbRun('DELETE FROM pharmacy_medicines');
await dbRun('DELETE FROM medicines');
await dbRun('DELETE FROM pharmacies');
// Insert pharmacies
const pharmacyIds = [];
for (const pharmacy of pharmacies) {
const result = await dbRun(
'INSERT INTO pharmacies (name, address, phone, latitude, longitude) VALUES (?, ?, ?, ?, ?)',
[pharmacy.name, pharmacy.address, pharmacy.phone, pharmacy.lat, pharmacy.lng]
);
pharmacyIds.push(result.lastID);
}
console.log(`Inserted ${pharmacyIds.length} pharmacies`);
// Insert medicines
const medicineIds = [];
for (const medicine of medicines) {
const result = await dbRun(
'INSERT INTO medicines (name, active_ingredient, dosage, form) VALUES (?, ?, ?, ?)',
[medicine.name, medicine.active_ingredient, medicine.dosage, medicine.form]
);
medicineIds.push(result.lastID);
}
console.log(`Inserted ${medicineIds.length} medicines`);
// Create pharmacy-medicine relationships
// Each medicine is available in 2-4 random pharmacies with random prices
let relationshipCount = 0;
for (let i = 0; i < medicineIds.length; i++) {
const medicineId = medicineIds[i];
const numPharmacies = Math.floor(Math.random() * 3) + 2; // 2-4 pharmacies
const selectedPharmacies = new Set();
while (selectedPharmacies.size < numPharmacies) {
selectedPharmacies.add(Math.floor(Math.random() * pharmacyIds.length));
}
for (const pharmacyIndex of selectedPharmacies) {
const pharmacyId = pharmacyIds[pharmacyIndex];
const price = (Math.random() * 20 + 5).toFixed(2); // Random price between 5-25
const stock = Math.floor(Math.random() * 50) + 10; // Random stock 10-60
// NOTA: Como ahora usamos CIMA API, este seed solo crea ejemplos
// En producción, deberías vincular usando nregistros reales de CIMA
const medicine = medicines[i];
await dbRun(
'INSERT INTO pharmacy_medicines (pharmacy_id, medicine_nregistro, medicine_name, price, stock) VALUES (?, ?, ?, ?, ?)',
[pharmacyId, `EXAMPLE_${medicineId}`, medicine.name, price, stock]
);
relationshipCount++;
}
}
console.log(`Created ${relationshipCount} pharmacy-medicine relationships`);
console.log('⚠️ NOTA: Los medicamentos de ejemplo usan IDs ficticios.');
console.log('Database seeding completed successfully!');
} catch (error) {
console.error('Error seeding database:', error);
} finally {
db.close();
}
}
seedDatabase();
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
// OpenTelemetry Node SDK bootstrap for FarmaClic backend.
// Started as a side-effect import from server.js (ESM).
//
// Env vars (set by docker-compose):
// OTEL_SERVICE_NAME — default: farmaclic-backend
// OTEL_EXPORTER_OTLP_ENDPOINT — OTLP gRPC endpoint (e.g. http://alloy:4317)
//
// Exports traces to the shared Grafana Alloy collector, where they are
// routed to Tempo.
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import * as resources from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions';
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
const serviceName = process.env.OTEL_SERVICE_NAME || 'farmaclic-backend';
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317';
const resource = typeof resources.resourceFromAttributes === 'function'
? resources.resourceFromAttributes({
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
})
: new resources.Resource({
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
});
const sdk = new NodeSDK({
resource,
traceExporter: new OTLPTraceExporter({ url: otlpEndpoint }),
instrumentations: [
getNodeAutoInstrumentations({
// Disable fs by default — it is noisy and rarely useful.
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
}),
new PinoInstrumentation(),
],
});
if (process.env.NODE_ENV !== 'test') {
sdk.start();
}
const shutdown = async () => {
try {
if (process.env.NODE_ENV !== 'test') {
await sdk.shutdown();
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('OpenTelemetry shutdown failed', err);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+8
View File
@@ -0,0 +1,8 @@
# FarmaFinder Mobile - Environment Variables
# API Configuration
# Change this to your production API URL
EXPO_PUBLIC_API_URL=http://localhost:3001/api
# For production builds, update this to:
# EXPO_PUBLIC_API_URL=https://api.yourdomain.com/api
+42
View File
@@ -0,0 +1,42 @@
# Expo
.expo/
dist/
web-build/
# Native
ios/
android/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# env files
.env
.env.local
.env.production
# EAS
eas-cli.json
# Dependencies
node_modules/
# OS
.DS_Store
Thumbs.db
# Build artifacts
*.apk
*.aab
*.ipa
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/frontend-mobile.iml" filepath="$PROJECT_DIR$/.idea/frontend-mobile.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
+20
View File
@@ -0,0 +1,20 @@
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+42
View File
@@ -0,0 +1,42 @@
{
"expo": {
"name": "FarmaFinder",
"slug": "farmafinder",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#007AFF"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.farmafinder.app",
"config": {
"usesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#007AFF"
},
"package": "com.farmafinder.app",
"googleServicesFile": "./google-services.json"
},
"plugins": [
"expo-router",
["expo-camera", {"cameraPermission": "Allow FarmaFinder to access your camera for scanning barcodes"}],
["expo-notifications", {"icon": "./assets/notification-icon.png", "color": "#007AFF"}]
],
"scheme": "farmafinder",
"extra": {
"eas": {
"projectId": ""
}
}
}
}
@@ -0,0 +1,41 @@
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Buscar',
tabBarIcon: ({ color, size }) => (
<Ionicons name="search" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="map"
options={{
title: 'Mapa',
tabBarIcon: ({ color, size }) => (
<Ionicons name="map" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Perfil',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useEffect } from 'react';
import { View, FlatList, StyleSheet, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { SearchBar } from '../../components/SearchBar';
import { MedicineCard } from '../../components/MedicineCard';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { useDebounce } from '../../hooks/useDebounce';
import { searchMedicines } from '../../services/medicines';
import { colors, spacing, borderRadius } from '../../constants/theme';
import { Medicine } from '../../types';
import { config } from '../../constants/config';
export default function HomeScreen() {
const router = useRouter();
const [query, setQuery] = useState('');
const [results, setResults] = useState<Medicine[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const debouncedQuery = useDebounce(query, config.SEARCH_DEBOUNCE_MS);
useEffect(() => {
if (debouncedQuery.length < 2) {
setResults([]);
return;
}
const fetchResults = async () => {
setIsLoading(true);
setError(null);
try {
const data = await searchMedicines(debouncedQuery);
setResults(data);
} catch (err) {
setError('Error al buscar medicamentos');
console.error(err);
} finally {
setIsLoading(false);
}
};
fetchResults();
}, [debouncedQuery]);
const handleSearch = (searchQuery: string) => {
setQuery(searchQuery);
};
return (
<View style={styles.container}>
<View style={styles.searchContainer}>
<SearchBar
onSearch={handleSearch}
value={query}
onChangeText={setQuery}
/>
<TouchableOpacity
style={styles.scannerButton}
onPress={() => router.push('/scanner')}
>
<Ionicons name="scan" size={24} color={colors.primary} />
</TouchableOpacity>
</View>
{isLoading && <LoadingSpinner message="Buscando medicamentos..." />}
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{!isLoading && !error && results.length === 0 && query.length >= 2 && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No se encontraron medicamentos</Text>
</View>
)}
<FlatList
data={results}
keyExtractor={(item) => item.nregistro}
renderItem={({ item }) => <MedicineCard medicine={item} />}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
},
scannerButton: {
marginRight: spacing.md,
padding: spacing.sm,
backgroundColor: colors.card,
borderRadius: borderRadius.md,
},
list: {
paddingBottom: spacing.xl,
},
errorContainer: {
padding: spacing.md,
marginHorizontal: spacing.md,
backgroundColor: '#F8D7DA',
borderRadius: 8,
},
errorText: {
color: '#721C24',
textAlign: 'center',
},
emptyContainer: {
padding: spacing.xl,
alignItems: 'center',
},
emptyText: {
color: colors.textSecondary,
fontSize: 16,
},
});
+108
View File
@@ -0,0 +1,108 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import { useRouter } from 'expo-router';
import { getPharmacies } from '../../services/pharmacies';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { colors, spacing } from '../../constants/theme';
import { Pharmacy } from '../../types';
export default function MapScreen() {
const router = useRouter();
const [pharmacies, setPharmacies] = useState<Pharmacy[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [region, setRegion] = useState({
latitude: 40.4168, // Madrid default
longitude: -3.7038,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
});
useEffect(() => {
const fetchPharmacies = async () => {
try {
const data = await getPharmacies();
setPharmacies(data);
// Center map on first pharmacy if available
if (data.length > 0) {
setRegion({
latitude: data[0].latitude,
longitude: data[0].longitude,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
});
}
} catch (error) {
console.error('Error fetching pharmacies:', error);
} finally {
setIsLoading(false);
}
};
fetchPharmacies();
}, []);
if (isLoading) {
return <LoadingSpinner message="Cargando farmacias..." />;
}
return (
<View style={styles.container}>
<MapView
style={styles.map}
region={region}
onRegionChangeComplete={setRegion}
showsUserLocation={true}
showsMyLocationButton={true}
>
{pharmacies.map((pharmacy) => (
<Marker
key={pharmacy.id}
coordinate={{
latitude: pharmacy.latitude,
longitude: pharmacy.longitude,
}}
title={pharmacy.name}
description={pharmacy.address}
onCalloutPress={() => router.push(`/pharmacy/${pharmacy.id}`)}
/>
))}
</MapView>
<View style={styles.legend}>
<Text style={styles.legendText}>
{pharmacies.length} farmacias en el mapa
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
map: {
flex: 1,
},
legend: {
position: 'absolute',
bottom: spacing.lg,
left: spacing.md,
right: spacing.md,
backgroundColor: colors.card,
borderRadius: 8,
padding: spacing.sm,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
legendText: {
fontSize: 14,
color: colors.text,
},
});
+219
View File
@@ -0,0 +1,219 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../../hooks/useAuth';
import { colors, spacing, borderRadius } from '../../constants/theme';
export default function ProfileScreen() {
const router = useRouter();
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth();
const handleLogout = async () => {
Alert.alert(
'Cerrar Sesión',
'¿Estás seguro que deseas cerrar sesión?',
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Cerrar Sesión',
style: 'destructive',
onPress: async () => {
await logout();
router.replace('/auth/login');
}
},
]
);
};
if (isLoading) {
return (
<View style={styles.container}>
<Text style={styles.loadingText}>Cargando...</Text>
</View>
);
}
if (!isAuthenticated) {
return (
<View style={styles.container}>
<View style={styles.authPrompt}>
<Ionicons name="person-outline" size={64} color={colors.textSecondary} />
<Text style={styles.authTitle}>Inicia Sesión</Text>
<Text style={styles.authSubtitle}>
Inicia sesión para acceder a todas las funcionalidades
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => router.push('/auth/login')}
>
<Text style={styles.buttonText}>Iniciar Sesión</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/auth/register')}
>
<Text style={styles.linkText}>Crear cuenta nueva</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.avatar}>
<Ionicons name="person" size={40} color={colors.primary} />
</View>
<Text style={styles.username}>{user?.username}</Text>
{isAdmin && <Text style={styles.adminBadge}>Administrador</Text>}
</View>
<View style={styles.menuSection}>
{isAdmin && (
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="settings" size={24} color={colors.text} />
<Text style={styles.menuText}>Panel Admin</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
)}
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="heart" size={24} color={colors.text} />
<Text style={styles.menuText}>Favoritos</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="notifications" size={24} color={colors.text} />
<Text style={styles.menuText}>Notificaciones</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="help-circle" size={24} color={colors.text} />
<Text style={styles.menuText}>Ayuda</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Ionicons name="log-out" size={20} color={colors.danger} />
<Text style={styles.logoutText}>Cerrar Sesión</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
loadingText: {
textAlign: 'center',
marginTop: spacing.xxl,
color: colors.textSecondary,
},
authPrompt: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
authTitle: {
fontSize: 24,
fontWeight: 'bold',
color: colors.text,
marginTop: spacing.lg,
},
authSubtitle: {
fontSize: 16,
color: colors.textSecondary,
textAlign: 'center',
marginTop: spacing.sm,
marginBottom: spacing.xl,
},
header: {
alignItems: 'center',
padding: spacing.xl,
backgroundColor: colors.card,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
username: {
fontSize: 20,
fontWeight: 'bold',
color: colors.text,
marginTop: spacing.md,
},
adminBadge: {
fontSize: 12,
color: colors.primary,
backgroundColor: '#E3F2FD',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.sm,
marginTop: spacing.sm,
},
menuSection: {
marginTop: spacing.md,
backgroundColor: colors.card,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.separator,
},
menuText: {
flex: 1,
marginLeft: spacing.md,
fontSize: 16,
color: colors.text,
},
button: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
},
buttonText: {
color: colors.textInverse,
fontSize: 16,
fontWeight: '600',
},
linkButton: {
marginTop: spacing.md,
},
linkText: {
color: colors.primary,
fontSize: 14,
},
logoutButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
margin: spacing.xl,
padding: spacing.md,
backgroundColor: colors.card,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.danger,
},
logoutText: {
marginLeft: spacing.sm,
color: colors.danger,
fontSize: 16,
fontWeight: '500',
},
});
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useAuthStore } from '../store/authStore';
import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications';
const queryClient = new QueryClient();
export default function RootLayout() {
const { checkAuth } = useAuthStore();
const notificationListener = useRef<ReturnType<typeof addNotificationListener>>();
const responseListener = useRef<ReturnType<typeof addNotificationResponseListener>>();
useEffect(() => {
checkAuth();
registerForPushNotifications();
notificationListener.current = addNotificationListener((notification) => {
console.log('Notification received:', notification);
});
responseListener.current = addNotificationResponseListener((response) => {
console.log('Notification clicked:', response);
});
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
};
}, []);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="medicine/[id]"
options={{
title: 'Medicamento',
headerTintColor: '#007AFF',
}}
/>
<Stack.Screen
name="pharmacy/[id]"
options={{
title: 'Farmacia',
headerTintColor: '#007AFF',
}}
/>
<Stack.Screen
name="auth/login"
options={{
title: 'Iniciar Sesión',
headerShown: false,
}}
/>
<Stack.Screen
name="auth/register"
options={{
title: 'Registrarse',
headerShown: false,
}}
/>
</Stack>
<StatusBar style="auto" />
</QueryClientProvider>
</GestureHandlerRootView>
);
}
+237
View File
@@ -0,0 +1,237 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/authStore';
import { colors, spacing, borderRadius } from '../../constants/theme';
import {
isBiometricsAvailable,
authenticateWithBiometrics,
saveBiometricCredentials,
getBiometricUsername
} from '../../services/biometrics';
export default function LoginScreen() {
const router = useRouter();
const { login } = useAuthStore();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
const [biometricUsername, setBiometricUsername] = useState<string | null>(null);
useEffect(() => {
checkBiometrics();
}, []);
const checkBiometrics = async () => {
try {
const available = await isBiometricsAvailable();
setBiometricsAvailable(available);
if (available) {
const savedUsername = await getBiometricUsername();
setBiometricUsername(savedUsername);
}
} catch (error) {
console.error('Error checking biometrics:', error);
}
};
const handleLogin = async () => {
if (!username || !password) {
Alert.alert('Error', 'Por favor ingresa usuario y contraseña');
return;
}
setIsLoading(true);
try {
await login(username, password);
// Save username for biometric login
if (biometricsAvailable) {
await saveBiometricCredentials(username);
}
router.replace('/(tabs)');
} catch (error) {
Alert.alert('Error', 'Credenciales incorrectas');
} finally {
setIsLoading(false);
}
};
const handleBiometricLogin = async () => {
if (!biometricUsername) {
Alert.alert('Error', 'No hay credenciales biométricas guardadas');
return;
}
setIsLoading(true);
try {
const authenticated = await authenticateWithBiometrics();
if (authenticated) {
// Use saved username with empty password for biometric login
// The backend should handle biometric authentication differently
await login(biometricUsername, '');
router.replace('/(tabs)');
} else {
Alert.alert('Error', 'Autenticación biométrica fallida');
}
} catch (error) {
Alert.alert('Error', 'Error en la autenticación biométrica');
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.form}>
<Text style={styles.title}>Iniciar Sesión</Text>
<Text style={styles.subtitle}>Ingresa tus credenciales para continuar</Text>
<View style={styles.inputContainer}>
<Text style={styles.label}>Usuario</Text>
<TextInput
style={styles.input}
value={username}
onChangeText={setUsername}
placeholder="Tu usuario"
placeholderTextColor={colors.textSecondary}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Contraseña</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Tu contraseña"
placeholderTextColor={colors.textSecondary}
secureTextEntry
/>
</View>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'Ingresando...' : 'Iniciar Sesión'}
</Text>
</TouchableOpacity>
{biometricsAvailable && biometricUsername && (
<TouchableOpacity
style={[styles.biometricButton, isLoading && styles.buttonDisabled]}
onPress={handleBiometricLogin}
disabled={isLoading}
>
<Ionicons name="finger-print" size={24} color={colors.primary} />
<Text style={styles.biometricText}>Iniciar con biometría</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/auth/register')}
>
<Text style={styles.linkText}>¿No tienes cuenta? Regístrate</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
form: {
flex: 1,
justifyContent: 'center',
padding: spacing.xl,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: colors.text,
marginBottom: spacing.sm,
},
subtitle: {
fontSize: 16,
color: colors.textSecondary,
marginBottom: spacing.xl,
},
inputContainer: {
marginBottom: spacing.md,
},
label: {
fontSize: 14,
fontWeight: '500',
color: colors.text,
marginBottom: spacing.xs,
},
input: {
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: 16,
color: colors.text,
},
button: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
padding: spacing.md,
alignItems: 'center',
marginTop: spacing.md,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: colors.textInverse,
fontSize: 16,
fontWeight: '600',
},
linkButton: {
marginTop: spacing.lg,
alignItems: 'center',
},
linkText: {
color: colors.primary,
fontSize: 14,
},
biometricButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
marginTop: spacing.md,
borderWidth: 1,
borderColor: colors.primary,
},
biometricText: {
color: colors.primary,
fontSize: 16,
fontWeight: '600',
marginLeft: spacing.sm,
},
});
+172
View File
@@ -0,0 +1,172 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert
} from 'react-native';
import { useRouter } from 'expo-router';
import { register } from '../../services/auth';
import { colors, spacing, borderRadius } from '../../constants/theme';
export default function RegisterScreen() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleRegister = async () => {
if (!username || !password || !confirmPassword) {
Alert.alert('Error', 'Por favor completa todos los campos');
return;
}
if (password !== confirmPassword) {
Alert.alert('Error', 'Las contraseñas no coinciden');
return;
}
setIsLoading(true);
try {
await register(username, password);
router.replace('/(tabs)');
} catch (error) {
Alert.alert('Error', 'No se pudo crear la cuenta');
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.form}>
<Text style={styles.title}>Crear Cuenta</Text>
<Text style={styles.subtitle}>Regístrate para empezar</Text>
<View style={styles.inputContainer}>
<Text style={styles.label}>Usuario</Text>
<TextInput
style={styles.input}
value={username}
onChangeText={setUsername}
placeholder="Elige un usuario"
placeholderTextColor={colors.textSecondary}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Contraseña</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Elige una contraseña"
placeholderTextColor={colors.textSecondary}
secureTextEntry
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Confirmar Contraseña</Text>
<TextInput
style={styles.input}
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Repite la contraseña"
placeholderTextColor={colors.textSecondary}
secureTextEntry
/>
</View>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'Creando cuenta...' : 'Crear Cuenta'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.back()}
>
<Text style={styles.linkText}>¿Ya tienes cuenta? Inicia sesión</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
form: {
flex: 1,
justifyContent: 'center',
padding: spacing.xl,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: colors.text,
marginBottom: spacing.sm,
},
subtitle: {
fontSize: 16,
color: colors.textSecondary,
marginBottom: spacing.xl,
},
inputContainer: {
marginBottom: spacing.md,
},
label: {
fontSize: 14,
fontWeight: '500',
color: colors.text,
marginBottom: spacing.xs,
},
input: {
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: 16,
color: colors.text,
},
button: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
padding: spacing.md,
alignItems: 'center',
marginTop: spacing.md,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: colors.textInverse,
fontSize: 16,
fontWeight: '600',
},
linkButton: {
marginTop: spacing.lg,
alignItems: 'center',
},
linkText: {
color: colors.primary,
fontSize: 14,
},
});
+205
View File
@@ -0,0 +1,205 @@
import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { getMedicine, getMedicinePharmacies } from '../../services/medicines';
import { StockBadge } from '../../components/StockBadge';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { colors, spacing, borderRadius } from '../../constants/theme';
import { Medicine, PharmacyMedicine } from '../../types';
export default function MedicineDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [medicine, setMedicine] = useState<Medicine | null>(null);
const [pharmacies, setPharmacies] = useState<PharmacyMedicine[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!id) return;
const fetchMedicine = async () => {
try {
const [medData, pharmData] = await Promise.all([
getMedicine(id),
getMedicinePharmacies(id),
]);
setMedicine(medData);
setPharmacies(pharmData);
} catch (error) {
console.error('Error fetching medicine:', error);
} finally {
setIsLoading(false);
}
};
fetchMedicine();
}, [id]);
if (isLoading) {
return <LoadingSpinner message="Cargando medicamento..." />;
}
if (!medicine) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Medicamento no encontrado</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.name}>{medicine.nombre}</Text>
<StockBadge stock={medicine.stock} />
</View>
<View style={styles.infoSection}>
<InfoRow label="Principio activo" value={medicine.principioActivo} />
<InfoRow label="Laboratorio" value={medicine.laboratorio} />
<InfoRow label="Forma farmacéutica" value={medicine.formaFarmaceutica} />
<InfoRow
label="Precio"
value={medicine.precio ? `${medicine.precio.toFixed(2)}` : 'No disponible'}
/>
<InfoRow label="Registro" value={medicine.nregistro} />
</View>
<View style={styles.pharmaciesSection}>
<Text style={styles.sectionTitle}>
Farmacias ({pharmacies.length})
</Text>
{pharmacies.length === 0 ? (
<Text style={styles.noPharmacies}>No hay farmacias con este medicamento</Text>
) : (
pharmacies.map((pharm) => (
<TouchableOpacity
key={pharm.id}
style={styles.pharmacyCard}
onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id}`)}
>
<View style={styles.pharmacyInfo}>
<Text style={styles.pharmacyName}>{pharm.pharmacy?.name}</Text>
<Text style={styles.pharmacyAddress}>{pharm.pharmacy?.address}</Text>
</View>
<View style={styles.pharmacyStock}>
<Text style={styles.price}>{pharm.price.toFixed(2)} </Text>
<Text style={styles.stock}>Stock: {pharm.stock}</Text>
</View>
</TouchableOpacity>
))
)}
</View>
</ScrollView>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
padding: spacing.md,
backgroundColor: colors.card,
},
name: {
flex: 1,
fontSize: 22,
fontWeight: 'bold',
color: colors.text,
marginRight: spacing.sm,
},
infoSection: {
backgroundColor: colors.card,
marginTop: spacing.sm,
padding: spacing.md,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.separator,
},
infoLabel: {
fontSize: 14,
color: colors.textSecondary,
},
infoValue: {
fontSize: 14,
color: colors.text,
fontWeight: '500',
},
pharmaciesSection: {
marginTop: spacing.sm,
padding: spacing.md,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.md,
},
noPharmacies: {
color: colors.textSecondary,
textAlign: 'center',
padding: spacing.xl,
},
pharmacyCard: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
marginBottom: spacing.sm,
},
pharmacyInfo: {
flex: 1,
},
pharmacyName: {
fontSize: 16,
fontWeight: '600',
color: colors.text,
},
pharmacyAddress: {
fontSize: 14,
color: colors.textSecondary,
marginTop: spacing.xs,
},
pharmacyStock: {
alignItems: 'flex-end',
},
price: {
fontSize: 16,
fontWeight: '600',
color: colors.primary,
},
stock: {
fontSize: 12,
color: colors.textSecondary,
marginTop: spacing.xs,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: colors.textSecondary,
},
});
+260
View File
@@ -0,0 +1,260 @@
import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import MapView, { Marker } from 'react-native-maps';
import { getPharmacy, getPharmacyMedicines } from '../../services/pharmacies';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { colors, spacing, borderRadius } from '../../constants/theme';
import { Pharmacy, PharmacyMedicine } from '../../types';
export default function PharmacyDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [pharmacy, setPharmacy] = useState<Pharmacy | null>(null);
const [medicines, setMedicines] = useState<PharmacyMedicine[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!id) return;
const fetchPharmacy = async () => {
try {
const [pharmData, medData] = await Promise.all([
getPharmacy(Number(id)),
getPharmacyMedicines(Number(id)),
]);
setPharmacy(pharmData);
setMedicines(medData as PharmacyMedicine[]);
} catch (error) {
console.error('Error fetching pharmacy:', error);
} finally {
setIsLoading(false);
}
};
fetchPharmacy();
}, [id]);
const handleCall = () => {
if (pharmacy?.phone) {
Linking.openURL(`tel:${pharmacy.phone}`);
}
};
const handleDirections = () => {
if (pharmacy) {
const url = `https://www.google.com/maps/dir/?api=1&destination=${pharmacy.latitude},${pharmacy.longitude}`;
Linking.openURL(url);
}
};
if (isLoading) {
return <LoadingSpinner message="Cargando farmacia..." />;
}
if (!pharmacy) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Farmacia no encontrada</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.name}>{pharmacy.name}</Text>
</View>
<View style={styles.actionsRow}>
<TouchableOpacity style={styles.actionButton} onPress={handleCall}>
<Ionicons name="call" size={20} color={colors.primary} />
<Text style={styles.actionText}>Llamar</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton} onPress={handleDirections}>
<Ionicons name="navigate" size={20} color={colors.primary} />
<Text style={styles.actionText}>Cómo llegar</Text>
</TouchableOpacity>
</View>
<View style={styles.infoSection}>
<View style={styles.infoRow}>
<Ionicons name="location" size={18} color={colors.textSecondary} />
<Text style={styles.infoText}>{pharmacy.address}</Text>
</View>
{pharmacy.phone && (
<View style={styles.infoRow}>
<Ionicons name="call" size={18} color={colors.textSecondary} />
<Text style={styles.infoText}>{pharmacy.phone}</Text>
</View>
)}
</View>
<View style={styles.mapContainer}>
<MapView
style={styles.map}
initialRegion={{
latitude: pharmacy.latitude,
longitude: pharmacy.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
scrollEnabled={false}
>
<Marker
coordinate={{
latitude: pharmacy.latitude,
longitude: pharmacy.longitude,
}}
title={pharmacy.name}
/>
</MapView>
</View>
<View style={styles.medicinesSection}>
<Text style={styles.sectionTitle}>
Medicamentos ({medicines.length})
</Text>
{medicines.length === 0 ? (
<Text style={styles.noMedicines}>No hay medicamentos disponibles</Text>
) : (
medicines.map((med) => (
<TouchableOpacity
key={med.id}
style={styles.medicineCard}
onPress={() => router.push(`/medicine/${med.medicine_nregistro}`)}
>
<View style={styles.medicineInfo}>
<Text style={styles.medicineName}>{med.medicine_name}</Text>
<Text style={styles.medicineNregistro}>Reg: {med.medicine_nregistro}</Text>
</View>
<View style={styles.medicineStock}>
<Text style={styles.price}>{med.price.toFixed(2)} </Text>
<Text style={styles.stock}>Stock: {med.stock}</Text>
</View>
</TouchableOpacity>
))
)}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
padding: spacing.md,
backgroundColor: colors.card,
},
name: {
fontSize: 22,
fontWeight: 'bold',
color: colors.text,
},
actionsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
padding: spacing.md,
backgroundColor: colors.card,
borderBottomWidth: 1,
borderBottomColor: colors.separator,
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.sm,
},
actionText: {
marginLeft: spacing.xs,
color: colors.primary,
fontWeight: '500',
},
infoSection: {
padding: spacing.md,
backgroundColor: colors.card,
marginTop: spacing.sm,
},
infoRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: spacing.sm,
},
infoText: {
flex: 1,
marginLeft: spacing.sm,
fontSize: 16,
color: colors.text,
},
mapContainer: {
height: 200,
marginTop: spacing.sm,
},
map: {
flex: 1,
},
medicinesSection: {
marginTop: spacing.sm,
padding: spacing.md,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.md,
},
noMedicines: {
color: colors.textSecondary,
textAlign: 'center',
padding: spacing.xl,
},
medicineCard: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
marginBottom: spacing.sm,
},
medicineInfo: {
flex: 1,
},
medicineName: {
fontSize: 16,
fontWeight: '500',
color: colors.text,
},
medicineNregistro: {
fontSize: 12,
color: colors.textSecondary,
marginTop: spacing.xs,
},
medicineStock: {
alignItems: 'flex-end',
},
price: {
fontSize: 16,
fontWeight: '600',
color: colors.primary,
},
stock: {
fontSize: 12,
color: colors.textSecondary,
marginTop: spacing.xs,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: colors.textSecondary,
},
});
+46
View File
@@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { BarcodeScanner } from '../components/BarcodeScanner';
import { searchMedicines } from '../services/medicines';
export default function ScannerScreen() {
const router = useRouter();
const [isSearching, setIsSearching] = useState(false);
const handleBarcodeScanned = async (barcode: string) => {
setIsSearching(true);
try {
const results = await searchMedicines(barcode);
if (results.length > 0) {
router.push(`/medicine/${results[0].nregistro}`);
} else {
router.push(`/medicine/${barcode}`);
}
} catch (error) {
console.error('Error searching medicine:', error);
router.push(`/medicine/${barcode}`);
} finally {
setIsSearching(false);
}
};
const handleClose = () => {
router.back();
};
return (
<View style={styles.container}>
<BarcodeScanner
onBarcodeScanned={handleBarcodeScanned}
onClose={handleClose}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

+7
View File
@@ -0,0 +1,7 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};
@@ -0,0 +1,207 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, borderRadius } from '../constants/theme';
interface BarcodeScannerProps {
onBarcodeScanned: (barcode: string) => void;
onClose: () => void;
}
export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProps) {
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
if (!permission) {
return <View style={styles.container} />;
}
if (!permission.granted) {
return (
<View style={styles.permissionContainer}>
<Ionicons name="camera" size={64} color={colors.textSecondary} />
<Text style={styles.permissionTitle}>Permiso de cámara requerido</Text>
<Text style={styles.permissionText}>
Necesitamos acceso a la cámara para escanear códigos de barras
</Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<Text style={styles.permissionButtonText}>Conceder permiso</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.cancelButton} onPress={onClose}>
<Text style={styles.cancelButtonText}>Cancelar</Text>
</TouchableOpacity>
</View>
);
}
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
if (scanned) return;
setScanned(true);
onBarcodeScanned(data);
};
return (
<View style={styles.container}>
<CameraView
style={StyleSheet.absoluteFillObject}
facing="back"
barcodeScannerSettings={{
barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e'],
}}
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
/>
<View style={styles.overlay}>
<View style={styles.scannerFrame}>
<View style={[styles.corner, styles.topLeft]} />
<View style={[styles.corner, styles.topRight]} />
<View style={[styles.corner, styles.bottomLeft]} />
<View style={[styles.corner, styles.bottomRight]} />
</View>
<Text style={styles.instruction}>
Apunta la cámara al código de barras del medicamento
</Text>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Ionicons name="close" size={24} color={colors.textInverse} />
</TouchableOpacity>
</View>
{scanned && (
<View style={styles.scannedOverlay}>
<TouchableOpacity
style={styles.scanAgainButton}
onPress={() => setScanned(false)}
>
<Text style={styles.scanAgainText}>Escanear de nuevo</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
permissionContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background,
padding: spacing.xl,
},
permissionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: colors.text,
marginTop: spacing.lg,
},
permissionText: {
fontSize: 16,
color: colors.textSecondary,
textAlign: 'center',
marginTop: spacing.sm,
marginBottom: spacing.xl,
},
permissionButton: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
},
permissionButtonText: {
color: colors.textInverse,
fontSize: 16,
fontWeight: '600',
},
cancelButton: {
marginTop: spacing.md,
},
cancelButtonText: {
color: colors.textSecondary,
fontSize: 14,
},
overlay: {
flex: 1,
backgroundColor: 'transparent',
justifyContent: 'center',
alignItems: 'center',
},
scannerFrame: {
width: 250,
height: 250,
borderWidth: 2,
borderColor: 'transparent',
position: 'relative',
},
corner: {
position: 'absolute',
width: 30,
height: 30,
borderColor: colors.primary,
},
topLeft: {
top: 0,
left: 0,
borderTopWidth: 3,
borderLeftWidth: 3,
},
topRight: {
top: 0,
right: 0,
borderTopWidth: 3,
borderRightWidth: 3,
},
bottomLeft: {
bottom: 0,
left: 0,
borderBottomWidth: 3,
borderLeftWidth: 3,
},
bottomRight: {
bottom: 0,
right: 0,
borderBottomWidth: 3,
borderRightWidth: 3,
},
instruction: {
color: colors.textInverse,
fontSize: 14,
textAlign: 'center',
marginTop: spacing.xl,
backgroundColor: 'rgba(0,0,0,0.6)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.sm,
},
closeButton: {
position: 'absolute',
top: spacing.xl,
right: spacing.xl,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 20,
padding: spacing.sm,
},
scannedOverlay: {
position: 'absolute',
bottom: spacing.xxl,
left: spacing.xl,
right: spacing.xl,
},
scanAgainButton: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
padding: spacing.md,
alignItems: 'center',
},
scanAgainText: {
color: colors.textInverse,
fontSize: 16,
fontWeight: '600',
},
});
@@ -0,0 +1,30 @@
import React from 'react';
import { View, ActivityIndicator, Text, StyleSheet } from 'react-native';
import { colors, spacing } from '../constants/theme';
interface LoadingSpinnerProps {
message?: string;
}
export function LoadingSpinner({ message = 'Cargando...' }: LoadingSpinnerProps) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.message}>{message}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
message: {
marginTop: spacing.md,
fontSize: 16,
color: colors.textSecondary,
},
});
@@ -0,0 +1,110 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, borderRadius } from '../constants/theme';
import { StockBadge } from './StockBadge';
import { Medicine } from '../types';
interface MedicineCardProps {
medicine: Medicine;
}
export function MedicineCard({ medicine }: MedicineCardProps) {
const router = useRouter();
const handlePress = () => {
router.push(`/medicine/${medicine.nregistro}`);
};
return (
<TouchableOpacity style={styles.card} onPress={handlePress} activeOpacity={0.7}>
<View style={styles.header}>
<Text style={styles.name} numberOfLines={2}>
{medicine.nombre}
</Text>
<StockBadge stock={medicine.stock} />
</View>
<Text style={styles.principioActivo} numberOfLines={1}>
{medicine.principioActivo}
</Text>
<View style={styles.footer}>
<View style={styles.priceContainer}>
<Ionicons name="pricetag" size={14} color={colors.textSecondary} />
<Text style={styles.price}>
{medicine.precio ? `${medicine.precio.toFixed(2)}` : 'Sin precio'}
</Text>
</View>
<View style={styles.labContainer}>
<Ionicons name="business" size={14} color={colors.textSecondary} />
<Text style={styles.laboratorio} numberOfLines={1}>
{medicine.laboratorio}
</Text>
</View>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
marginHorizontal: spacing.md,
marginVertical: spacing.xs,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: spacing.xs,
},
name: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: colors.text,
marginRight: spacing.sm,
},
principioActivo: {
fontSize: 14,
color: colors.textSecondary,
marginBottom: spacing.sm,
},
footer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
priceContainer: {
flexDirection: 'row',
alignItems: 'center',
},
price: {
fontSize: 14,
fontWeight: '600',
color: colors.text,
marginLeft: spacing.xs,
},
labContainer: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
justifyContent: 'flex-end',
},
laboratorio: {
fontSize: 12,
color: colors.textSecondary,
marginLeft: spacing.xs,
maxWidth: 120,
},
});
@@ -0,0 +1,81 @@
import React, { useState } from 'react';
import { View, TextInput, StyleSheet, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, borderRadius } from '../constants/theme';
interface SearchBarProps {
placeholder?: string;
onSearch: (query: string) => void;
value?: string;
onChangeText?: (text: string) => void;
}
export function SearchBar({
placeholder = 'Buscar medicamentos...',
onSearch,
value,
onChangeText
}: SearchBarProps) {
const [localValue, setLocalValue] = useState(value || '');
const handleChange = (text: string) => {
setLocalValue(text);
onChangeText?.(text);
};
const handleSubmit = () => {
onSearch(localValue);
};
const handleClear = () => {
setLocalValue('');
onChangeText?.('');
onSearch('');
};
return (
<View style={styles.container}>
<Ionicons name="search" size={20} color={colors.textSecondary} style={styles.icon} />
<TextInput
style={styles.input}
placeholder={placeholder}
placeholderTextColor={colors.textSecondary}
value={localValue}
onChangeText={handleChange}
onSubmitEditing={handleSubmit}
returnKeyType="search"
autoCorrect={false}
/>
{localValue.length > 0 && (
<TouchableOpacity onPress={handleClear} style={styles.clearButton}>
<Ionicons name="close-circle" size={20} color={colors.textSecondary} />
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginHorizontal: spacing.md,
marginVertical: spacing.sm,
},
icon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
fontSize: 16,
color: colors.text,
paddingVertical: spacing.xs,
},
clearButton: {
marginLeft: spacing.sm,
},
});
@@ -0,0 +1,48 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { colors, borderRadius, spacing } from '../constants/theme';
interface StockBadgeProps {
stock: number;
}
export function StockBadge({ stock }: StockBadgeProps) {
const getBadgeStyle = () => {
if (stock === 0) return styles.danger;
if (stock < 5) return styles.warning;
return styles.success;
};
const getText = () => {
if (stock === 0) return 'Sin stock';
if (stock < 5) return `Bajo (${stock})`;
return `Disponible (${stock})`;
};
return (
<View style={[styles.badge, getBadgeStyle()]}>
<Text style={styles.text}>{getText()}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.sm,
},
success: {
backgroundColor: '#D4EDDA',
},
warning: {
backgroundColor: '#FFF3CD',
},
danger: {
backgroundColor: '#F8D7DA',
},
text: {
fontSize: 12,
fontWeight: '600',
},
});
+19
View File
@@ -0,0 +1,19 @@
import Constants from 'expo-constants';
const ENV = {
development: {
API_BASE_URL: 'http://localhost:3001/api',
},
production: {
API_BASE_URL: 'https://your-production-api.com/api',
},
};
const environment = Constants.expoConfig?.extra?.environment || (__DEV__ ? 'development' : 'production');
export const config = {
API_BASE_URL: ENV[environment as keyof typeof ENV]?.API_BASE_URL || ENV.development.API_BASE_URL,
SEARCH_DEBOUNCE_MS: 300,
CACHE_STALE_TIME: 5 * 60 * 1000, // 5 minutes
CACHE_CACHE_TIME: 30 * 60 * 1000, // 30 minutes
};
+58
View File
@@ -0,0 +1,58 @@
export const colors = {
// Primary
primary: '#007AFF',
primaryLight: '#4DA3FF',
primaryDark: '#0056CC',
// Semantic
success: '#34C759',
danger: '#FF3B30',
warning: '#FF9500',
// Neutral
background: '#F2F2F7',
card: '#FFFFFF',
border: '#E5E5EA',
separator: '#C6C6C8',
// Text
text: '#1C1C1E',
textSecondary: '#8E8E93',
textInverse: '#FFFFFF',
textLink: '#007AFF',
};
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
};
export const borderRadius = {
sm: 8,
md: 12,
lg: 16,
xl: 24,
};
export const typography = {
title: {
fontSize: 28,
fontWeight: 'bold' as const,
},
heading: {
fontSize: 20,
fontWeight: '600' as const,
},
body: {
fontSize: 16,
fontWeight: 'normal' as const,
},
caption: {
fontSize: 12,
fontWeight: 'normal' as const,
},
};
+46
View File
@@ -0,0 +1,46 @@
{
"cli": {
"version": ">= 12.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
},
"android": {
"buildType": "apk"
}
},
"preview": {
"distribution": "internal",
"ios": {
"buildType": "preview"
},
"android": {
"buildType": "apk"
}
},
"production": {
"ios": {
"buildType": "release"
},
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "",
"ascAppId": "",
"appleTeamId": ""
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { useAuthStore } from '../store/authStore';
export function useAuth() {
const { user, isLoading, isAuthenticated, login, logout, checkAuth } = useAuthStore();
useEffect(() => {
checkAuth();
}, []);
return {
user,
isLoading,
isAuthenticated,
login,
logout,
isAdmin: user?.is_admin ?? false,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
+8
View File
@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "frontend-mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@tanstack/react-query": "^5.101.2",
"axios": "^1.18.1",
"babel-preset-expo": "^57.0.1",
"expo": "~57.0.2",
"expo-camera": "~57.0.0",
"expo-constants": "~57.0.3",
"expo-dev-client": "~57.0.5",
"expo-device": "~7.0.2",
"expo-linking": "~57.0.1",
"expo-local-authentication": "~57.0.0",
"expo-notifications": "~57.0.3",
"expo-router": "~57.0.3",
"expo-secure-store": "~57.0.0",
"expo-status-bar": "~57.0.0",
"react": "19.2.7",
"react-native": "0.86.0",
"react-native-gesture-handler": "~2.32.0",
"react-native-maps": "^1.29.0",
"react-native-reanimated": "4.5.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-worklets": "0.10.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/react": "~19.2.7",
"typescript": "~6.0.3"
},
"scripts": {
"dev": "expo start",
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"private": true
}
+36
View File
@@ -0,0 +1,36 @@
import axios from 'axios';
import * as SecureStore from 'expo-secure-store';
import { config } from '../constants/config';
export const api = axios.create({
baseURL: config.API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - add auth token
api.interceptors.request.use(
async (axiosConfig) => {
const token = await SecureStore.getItemAsync('auth_token');
if (token) {
axiosConfig.headers.Authorization = `Bearer ${token}`;
}
return axiosConfig;
},
(error) => Promise.reject(error)
);
// Response interceptor - handle errors
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
await SecureStore.deleteItemAsync('auth_token');
}
return Promise.reject(error);
}
);
export default api;
+43
View File
@@ -0,0 +1,43 @@
import api from './api';
import * as SecureStore from 'expo-secure-store';
import { AuthResponse, User } from '../types';
export async function login(username: string, password: string): Promise<AuthResponse> {
const response = await api.post('/auth/login', { username, password });
const { user, token } = response.data;
await SecureStore.setItemAsync('auth_token', token);
await SecureStore.setItemAsync('user', JSON.stringify(user));
return response.data;
}
export async function logout(): Promise<void> {
await api.post('/auth/logout');
await SecureStore.deleteItemAsync('auth_token');
await SecureStore.deleteItemAsync('user');
}
export async function checkAuth(): Promise<User | null> {
try {
const response = await api.get('/auth/check');
return response.data.user;
} catch {
return null;
}
}
export async function getStoredUser(): Promise<User | null> {
const userStr = await SecureStore.getItemAsync('user');
return userStr ? JSON.parse(userStr) : null;
}
export async function register(username: string, password: string): Promise<AuthResponse> {
const response = await api.post('/auth/register', { username, password });
const { user, token } = response.data;
await SecureStore.setItemAsync('auth_token', token);
await SecureStore.setItemAsync('user', JSON.stringify(user));
return response.data;
}
@@ -0,0 +1,34 @@
import * as LocalAuthentication from 'expo-local-authentication';
import * as SecureStore from 'expo-secure-store';
export async function isBiometricsAvailable(): Promise<boolean> {
const compatible = await LocalAuthentication.hasHardwareAsync();
const enrolled = await LocalAuthentication.isEnrolledAsync();
return compatible && enrolled;
}
export async function authenticateWithBiometrics(): Promise<boolean> {
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Inicia sesión con biometría',
cancelLabel: 'Cancelar',
disableDeviceFallback: false,
});
return result.success;
} catch (error) {
console.error('Biometric authentication error:', error);
return false;
}
}
export async function saveBiometricCredentials(username: string): Promise<void> {
await SecureStore.setItemAsync('biometric_username', username);
}
export async function getBiometricUsername(): Promise<string | null> {
return await SecureStore.getItemAsync('biometric_username');
}
export async function clearBiometricCredentials(): Promise<void> {
await SecureStore.deleteItemAsync('biometric_username');
}
@@ -0,0 +1,17 @@
import api from './api';
import { Medicine, PharmacyMedicine } from '../types';
export async function searchMedicines(query: string): Promise<Medicine[]> {
const response = await api.get(`/medicines/search?q=${encodeURIComponent(query)}`);
return response.data;
}
export async function getMedicine(nregistro: string): Promise<Medicine> {
const response = await api.get(`/medicines/${nregistro}`);
return response.data;
}
export async function getMedicinePharmacies(nregistro: string): Promise<PharmacyMedicine[]> {
const response = await api.get(`/medicines/${nregistro}/pharmacies`);
return response.data;
}
@@ -0,0 +1,82 @@
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import * as Constants from 'expo-constants';
import { Platform } from 'react-native';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export async function registerForPushNotifications() {
if (!Device.isDevice) {
console.log('Push notifications require a physical device');
return null;
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Failed to get push token for push notification');
return null;
}
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#007AFF',
});
}
try {
const projectId =
Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
const token = await Notifications.getExpoPushTokenAsync({
projectId,
});
return token.data;
} catch (e) {
console.log('Error getting push token:', e);
return null;
}
}
export async function scheduleMedicineAvailabilityNotification(
medicineName: string,
pharmacyName: string
) {
await Notifications.scheduleNotificationAsync({
content: {
title: 'Medicamento disponible',
body: `${medicineName} está disponible en ${pharmacyName}`,
data: { type: 'medicine_availability' },
},
trigger: null,
});
}
export function addNotificationListener(
handler: (notification: Notifications.Notification) => void
): Notifications.EventSubscription {
return Notifications.addNotificationReceivedListener(handler);
}
export function addNotificationResponseListener(
handler: (response: Notifications.NotificationResponse) => void
): Notifications.EventSubscription {
return Notifications.addNotificationResponseReceivedListener(handler);
}
@@ -0,0 +1,17 @@
import api from './api';
import { Pharmacy } from '../types';
export async function getPharmacies(): Promise<Pharmacy[]> {
const response = await api.get('/pharmacies');
return response.data;
}
export async function getPharmacy(id: number): Promise<Pharmacy> {
const response = await api.get(`/pharmacies/${id}`);
return response.data;
}
export async function getPharmacyMedicines(id: number): Promise<any[]> {
const response = await api.get(`/pharmacies/${id}/medicines`);
return response.data;
}
+38
View File
@@ -0,0 +1,38 @@
import { create } from 'zustand';
import { User } from '../types';
import * as authService from '../services/auth';
interface AuthState {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
isAuthenticated: false,
login: async (username: string, password: string) => {
const { user } = await authService.login(username, password);
set({ user, isAuthenticated: true });
},
logout: async () => {
await authService.logout();
set({ user: null, isAuthenticated: false });
},
checkAuth: async () => {
set({ isLoading: true });
const user = await authService.checkAuth() || await authService.getStoredUser();
set({
user,
isAuthenticated: !!user,
isLoading: false
});
},
}));
+27
View File
@@ -0,0 +1,27 @@
import { create } from 'zustand';
import { Medicine } from '../types';
interface SearchState {
query: string;
results: Medicine[];
isLoading: boolean;
error: string | null;
setQuery: (query: string) => void;
setResults: (results: Medicine[]) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearResults: () => void;
}
export const useSearchStore = create<SearchState>((set) => ({
query: '',
results: [],
isLoading: false,
error: null,
setQuery: (query) => set({ query }),
setResults: (results) => set({ results, isLoading: false }),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error, isLoading: false }),
clearResults: () => set({ results: [], query: '', error: null }),
}));
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx"
]
}
+51
View File
@@ -0,0 +1,51 @@
export interface Medicine {
nregistro: string;
nombre: string;
principioActivo: string;
laboratorio: string;
formaFarmaceutica: string;
precio: number | null;
stock: number;
disponibilidad: 'disponible' | 'sin_stock' | 'bajo_stock';
}
export interface Pharmacy {
id: number;
name: string;
address: string;
phone: string;
latitude: number;
longitude: number;
}
export interface PharmacyMedicine {
id: number;
pharmacy_id: number;
medicine_nregistro: string;
medicine_name: string;
price: number;
stock: number;
pharmacy?: Pharmacy;
}
export interface User {
id: number;
username: string;
is_admin: boolean;
}
export interface AuthResponse {
user: User;
token: string;
}
export interface SearchResponse {
medicines: Medicine[];
total: number;
}
export interface ApiResponse<T> {
data: T;
success: boolean;
error?: string;
}
+18
View File
@@ -0,0 +1,18 @@
# Grafana Faro (browser RUM) — Vite build-time env vars.
# These are inlined into the production bundle at build time.
# Copy to .env and adjust as needed for local dev.
# OTLP HTTP endpoint of the Grafana Alloy collector.
# In Docker on the same host as Alloy, use http://localhost:4318 (the
# container's perspective of the host). For external deploys, use the
# Alloy's public URL, e.g. https://alloy.example.com.
VITE_FARO_ENDPOINT=http://localhost:4318
# App name shown in Grafana Explore / Faro data source.
VITE_FARO_APP_NAME=farmaclic-frontend
# Environment label (production | staging | development).
VITE_FARO_ENV=production
# App version (set by CI from the package version or git tag).
VITE_FARO_APP_VERSION=1.0.0
+19
View File
@@ -0,0 +1,19 @@
FROM node:20-alpine AS build
ARG VITE_FARO_ENDPOINT
ARG VITE_FARO_APP_NAME=farmaclic-frontend
ARG VITE_FARO_ENV=production
ARG VITE_FARO_APP_VERSION=1.0.0
ENV VITE_FARO_ENDPOINT=$VITE_FARO_ENDPOINT
ENV VITE_FARO_APP_NAME=$VITE_FARO_APP_NAME
ENV VITE_FARO_ENV=$VITE_FARO_ENV
ENV VITE_FARO_APP_VERSION=$VITE_FARO_APP_VERSION
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<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" />
<link rel="apple-touch-icon" href="/farmaclic_logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
+16
View File
@@ -0,0 +1,16 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+8585
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "farma-clic-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"@capacitor-mlkit/barcode-scanning": "^8.1.0",
"@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.4.1",
"@capacitor/splash-screen": "^8.0.1",
"@capacitor/status-bar": "^8.0.2",
"@grafana/faro-react": "^1.7.0",
"@grafana/faro-transport-otlp-http": "^1.7.0",
"@grafana/faro-web-sdk": "^1.7.0",
"@grafana/faro-web-tracing": "^1.7.0",
"@zxing/browser": "^0.2.0",
"@zxing/library": "^0.23.0",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.2.0",
"@vitejs/plugin-react": "^4.2.1",
"jsdom": "^24.0.0",
"vite": "^5.0.8",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^1.6.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#2563eb"/>
<g fill="#ffffff">
<rect x="216" y="160" width="80" height="192" rx="20"/>
<rect x="160" y="216" width="192" height="80" rx="20"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 268 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#2563eb"/>
<g fill="#ffffff">
<rect x="216" y="120" width="80" height="272" rx="20"/>
<rect x="120" y="216" width="272" height="80" rx="20"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+149
View File
@@ -0,0 +1,149 @@
.app {
height: 100dvh;
max-width: 48rem;
margin: 0 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-content {
flex: 1;
min-height: 0;
overflow-y: auto;
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: 0 5rem;
}
.home-card {
min-height: 3.5rem;
padding: 0.5rem 0.875rem;
}
}
body {
min-height: 100vh;
font-size: calc(16px + 0.5vmin);
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
color: var(--on-surface);
background-color: var(--surface-container-lowest);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Touch target sizing for mobile */
@media (max-width: 768px) {
a, button {
min-height: 4rem;
padding: 1rem;
}
}
/* Card styling improvements */
.home-card {
width: 100%;
max-width: 28rem;
min-height: fit-content;
transition: transform 0.15s, box-shadow 0.15s;
}
.home-card:hover {
transform: translateY(-3px);
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.1);
}
/* Fluid typography */
.home-desc {
font-size: clamp(1rem, 100vw, 1.5rem);
max-width: 100%;
}
.home-card-label {
font-size: clamp(1rem, 100vw, 1.25rem);
}
/* Global button styles using pastel variables */
button,
.btn {
appearance: none;
background: var(--primary);
color: var(--on-primary);
border: none;
padding: 0.6rem 1rem;
border-radius: var(--radius);
box-shadow: var(--primary-shadow);
transition: background-color 160ms ease, transform 120ms ease, box-shadow 160ms ease;
cursor: pointer;
}
button:hover,
.btn:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
button:focus,
.btn:focus {
outline: none;
box-shadow: 0 0 0 6px var(--primary-ring);
}
/* Outline / subtle buttons */
.btn--outline {
background: transparent;
color: var(--on-secondary);
border: 1px solid var(--secondary-container);
}
.btn--secondary {
background: var(--secondary);
color: var(--on-secondary);
}
.btn--ghost {
background: transparent;
color: var(--on-surface);
}
+189
View File
@@ -0,0 +1,189 @@
import React, { useState, useEffect } from 'react';
import './App.css';
import HomeView from './views/HomeView';
import SearchView from './views/SearchView';
import ScannerView from './views/ScannerView';
import AlertsView from './views/AlertsView';
import ProfileView from './views/ProfileView';
import AdminView from './views/AdminView';
import LoginModal from './components/LoginModal';
import SavedNotifications from './components/SavedNotifications';
import BottomNav from './components/BottomNav';
function App() {
const [screen, setScreen] = useState('home');
const [currentUser, setCurrentUser] = useState(null);
const [authChecked, setAuthChecked] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const [showSaved, setShowSaved] = useState(false);
const [badgeCount, setBadgeCount] = useState(0);
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);
}, []);
useEffect(() => {
if (!currentUser) { setBadgeCount(0); return; }
let cancelled = false;
fetch('/api/notifications/mine', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (cancelled || !data) return;
const count = (data.global?.length || 0) + (data.pharmacy?.length || 0);
setBadgeCount(count);
})
.catch(() => {});
return () => { cancelled = true; };
}, [currentUser]);
function refreshBadgeCount() {
if (!currentUser) return;
fetch('/api/notifications/mine', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
setBadgeCount((data.global?.length || 0) + (data.pharmacy?.length || 0));
})
.catch(() => {});
}
function handleLogin(user) {
setCurrentUser(user);
setShowLogin(false);
}
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
setCurrentUser(null);
setScreen('home');
}
function handleProfileSaved(updated) {
setCurrentUser(prev => ({ ...prev, ...updated }));
}
function handleAdminClick() {
setScreen('admin');
}
const isLoggedIn = Boolean(currentUser);
function handleNavChange(tab) {
if (tab === 'home') { setScreen('home'); return; }
if (tab === 'search') { setScreen('search'); return; }
if (tab === 'scan') { setScreen('scan'); return; }
if (tab === 'alerts') {
if (currentUser) setScreen('alerts');
else setShowLogin(true);
return;
}
if (tab === 'profile') {
if (currentUser) setScreen('profile');
else setShowLogin(true);
return;
}
}
let activeView;
let deviceClass = isMobile ? 'mobile-view' : 'desktop-view';
switch (screen) {
case 'profile':
activeView = (
<ProfileView
currentUser={currentUser}
onProfileSaved={handleProfileSaved}
onShowSaved={() => setShowSaved(true)}
onLogout={handleLogout}
onAdminClick={handleAdminClick}
/>
);
break;
case 'alerts':
activeView = <AlertsView onNotificationChange={refreshBadgeCount} />;
break;
case 'search':
activeView = (
<SearchView
currentUser={currentUser}
onLoginRequest={() => setShowLogin(true)}
/>
);
break;
case 'scan':
activeView = (
<ScannerView
onClose={() => setScreen('home')}
onSelectMedicine={(name) => {
setScreen('home');
}}
/>
);
break;
case 'admin':
activeView = <AdminView />;
break;
default:
activeView = (
<HomeView
currentUser={currentUser}
onLoginRequest={() => setShowLogin(true)}
onScanClick={() => setScreen('scan')}
onSearchClick={() => setScreen('search')}
/>
);
break;
}
return (
<div className={`app ${deviceClass}`}>
<div className="app-content">
{activeView}
</div>
<BottomNav
activeTab={screen}
onChange={handleNavChange}
isLoggedIn={Boolean(currentUser)}
badgeCount={badgeCount}
/>
{showLogin && (
<LoginModal
onLogin={handleLogin}
onClose={() => setShowLogin(false)}
/>
)}
{showSaved && currentUser && (
<SavedNotifications onClose={() => setShowSaved(false)} onNotificationChange={refreshBadgeCount} />
)}
</div>
);
}
export default App;

Some files were not shown because too many files have changed in this diff Show More