Restructure with Turborepo
@@ -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 |
@@ -0,0 +1,143 @@
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
z-index: 50;
|
||||
padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
|
||||
background: var(--surface-container-lowest);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
height: 5.5rem;
|
||||
flex-shrink: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Desktop navigation - full width */
|
||||
@media (min-width: 1025px) {
|
||||
.bottom-nav {
|
||||
max-width: 100%;
|
||||
padding: 0.25rem 2rem calc(0.5rem + env(safe-area-inset-bottom));
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.15rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--on-surface-variant);
|
||||
font: inherit;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: color 0.15s;
|
||||
position: relative;
|
||||
min-width: 3.5rem;
|
||||
}
|
||||
|
||||
.bottom-nav-item.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nav-icon-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
color: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-fab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
/* Stronger, brighter blue to make scan button easy to find */
|
||||
background: #2b5bb5;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 8px 26px rgba(43, 91, 181, 0.32);
|
||||
margin-top: -1.5rem;
|
||||
transition: transform 0.12s, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.nav-fab:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.nav-fab:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 36px rgba(43, 91, 181, 0.36);
|
||||
}
|
||||
|
||||
.nav-fab:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 6px rgba(43,91,181,0.12), 0 8px 26px rgba(43, 91, 181, 0.32);
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -2px;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
background: var(--error);
|
||||
color: var(--on-error);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--surface-container-lowest);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.nav-label--active {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-indicator {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.bottom-nav-item.active .nav-icon-wrap {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.bottom-nav-item.active .nav-icon-wrap svg {
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.bottom-nav-item:hover {
|
||||
color: var(--primary);
|
||||
background: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-elevated.active .nav-label {
|
||||
color: var(--tertiary);
|
||||
}
|
||||