Restructure with Turborepo
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "ffaa53eb-cd84-4686-bad7-0a2c3b7da73b"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:18-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY 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 backend/ .
|
||||||
|
COPY API/ /API/
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -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:00–24: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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {},
|
||||||
|
moduleFileExtensions: ['js', 'json'],
|
||||||
|
testMatch: ['**/__tests__/**/*.test.js'],
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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 --experimental-vm-modules node_modules/jest/bin/jest.js --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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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 ""
|
||||||
@@ -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();
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="StudioBotProjectSettings">
|
||||||
|
<option name="shareContext" value="OptedIn" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 384 KiB |
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.22.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 149 KiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 34 KiB |
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
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 [screenSize, setScreenSize] = useState({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
});
|
||||||
|
|
||||||
|
// Device detection
|
||||||
|
const isMobile = screenSize.width <= 768;
|
||||||
|
const isTablet = screenSize.width > 768 && screenSize.width <= 1024;
|
||||||
|
const isDesktop = screenSize.width > 1024;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set initial screen size
|
||||||
|
const handleResize = () => {
|
||||||
|
setScreenSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/api/auth/check')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { if (data.authenticated) setCurrentUser(data.user); })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setAuthChecked(true));
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleLogin(user) {
|
||||||
|
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 />;
|
||||||
|
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={currentUser ? 2 : 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showLogin && (
|
||||||
|
<LoginModal
|
||||||
|
onLogin={handleLogin}
|
||||||
|
onClose={() => setShowLogin(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSaved && currentUser && (
|
||||||
|
<SavedNotifications onClose={() => setShowSaved(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||||
|
import HomeView from './views/HomeView.jsx'
|
||||||
|
import SearchView from './views/SearchView.jsx'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HomeView', () => {
|
||||||
|
it('renders two action buttons on the home screen', () => {
|
||||||
|
const onSearch = vi.fn()
|
||||||
|
const onScan = vi.fn()
|
||||||
|
render(<HomeView onSearchClick={onSearch} onScanClick={onScan} />)
|
||||||
|
expect(screen.getByRole('button', { name: /buscar medicamento/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /escanear tsi/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSearchClick when Buscar Medicamento is clicked', async () => {
|
||||||
|
const onSearch = vi.fn()
|
||||||
|
render(<HomeView onSearchClick={onSearch} onScanClick={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /buscar medicamento/i }))
|
||||||
|
expect(onSearch).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SearchView', () => {
|
||||||
|
it('renders search bar with placeholder', () => {
|
||||||
|
render(<SearchView />)
|
||||||
|
expect(screen.getByPlaceholderText(/escriba el nombre del medicamento/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fetch for queries shorter than 2 chars', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
|
render(<SearchView />)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/escriba el nombre del medicamento/i), {
|
||||||
|
target: { value: 'a' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches and displays results for valid query', async () => {
|
||||||
|
const medicines = [
|
||||||
|
{ id: 'REG001', name: 'Ibuprofeno 400mg', active_ingredient: 'Ibuprofeno', dosage: '400mg', form: 'Comprimido' },
|
||||||
|
]
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
json: async () => medicines,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<SearchView />)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/escriba el nombre del medicamento/i), {
|
||||||
|
target: { value: 'ibu' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('Ibuprofeno 400mg')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears results when query is cleared after a search', async () => {
|
||||||
|
const medicines = [
|
||||||
|
{ id: 'REG001', name: 'Ibuprofeno 400mg', active_ingredient: 'Ibuprofeno', dosage: '400mg', form: 'Comprimido' },
|
||||||
|
]
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
json: async () => medicines,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<SearchView />)
|
||||||
|
const input = screen.getByPlaceholderText(/escriba el nombre del medicamento/i)
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'ibu' } })
|
||||||
|
await act(async () => { await vi.runAllTimersAsync() })
|
||||||
|
expect(screen.getByText('Ibuprofeno 400mg')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: '' } })
|
||||||
|
await act(async () => { await vi.runAllTimersAsync() })
|
||||||
|
|
||||||
|
expect(screen.queryByText('Ibuprofeno 400mg')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 1.7 MiB |