From a58ce306bffacf7d280cbe2ed7539ba5e688f3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Tue, 7 Jul 2026 17:44:36 +0200 Subject: [PATCH] Missing files from commit --- apps/backend/import-farmacias.js | 53 +++- apps/backend/server.js | 227 ++++++++++++------ apps/frontend-mobile/app/medicine/[id].tsx | 69 ++++-- apps/frontend/src/App.jsx | 10 +- apps/frontend/src/components/PharmacyList.css | 22 ++ apps/frontend/src/components/PharmacyList.jsx | 20 +- apps/frontend/src/utils/geo.js | 161 ++++++++++++- apps/frontend/src/views/AlertsView.jsx | 10 +- apps/frontend/src/views/SearchView.css | 21 ++ apps/frontend/src/views/SearchView.jsx | 30 ++- 10 files changed, 497 insertions(+), 126 deletions(-) diff --git a/apps/backend/import-farmacias.js b/apps/backend/import-farmacias.js index adef7e5..99e097c 100644 --- a/apps/backend/import-farmacias.js +++ b/apps/backend/import-farmacias.js @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * CLI: pull pharmacies from webhook and insert into database.sqlite + * CLI: pull pharmacies from webhook and insert into database * * npm run import-farmacias * FARMACIAS_WEBHOOK_URL=https://... npm run import-farmacias @@ -10,12 +10,15 @@ * 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 + * + * Uses PostgreSQL when PG_URL is set, otherwise falls back to local SQLite. */ import sqlite3 from 'sqlite3'; import { promisify } from 'util'; import path from 'path'; import { fileURLToPath } from 'url'; +import pg from 'pg'; import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, @@ -24,19 +27,43 @@ import { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const dbPath = path.join(__dirname, 'database.sqlite'); -const db = new sqlite3.Database(dbPath); +const PG_URL = process.env.PG_URL; +let pool = null; +let db = null; -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 }); +let dbRun, dbGet; + +if (PG_URL) { + pool = new pg.Pool({ connectionString: PG_URL }); + + function toPositional(sql) { + let i = 0; + return sql.replace(/\?/g, () => `$${++i}`); + } + + dbGet = async (sql, params = []) => { + const res = await pool.query(toPositional(sql), params); + return res.rows[0] ?? null; + }; + + dbRun = async (sql, params = []) => { + const res = await pool.query(toPositional(sql), params); + return { lastID: res.rows[0]?.id, changes: res.rowCount }; + }; +} else { + const dbPath = path.join(__dirname, 'database.sqlite'); + db = new sqlite3.Database(dbPath); + + dbRun = (sql, params = []) => + 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)); + dbGet = promisify(db.get.bind(db)); +} function parseCli(argv) { const region = {}; @@ -76,6 +103,7 @@ async function main() { const { url, region } = parseCli(process.argv); console.log('Fetching pharmacies from:', url); if (region) console.log('Region query:', region); + console.log('Using:', PG_URL ? 'PostgreSQL' : 'SQLite'); try { const result = await runFarmaciaWebhookImport(dbGet, dbRun, url, region); @@ -97,7 +125,8 @@ async function main() { } process.exitCode = 1; } finally { - db.close(); + if (pool) await pool.end(); + else if (db) db.close(); } } diff --git a/apps/backend/server.js b/apps/backend/server.js index 74d7480..eaaf23a 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -175,42 +175,68 @@ function serializeOpeningHours(value) { // 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, - opening_hours TEXT - ) - `); - - // Add opening_hours to pre-existing pharmacies tables (no-op if already present) - try { - await dbRun(`ALTER TABLE pharmacies ADD COLUMN opening_hours TEXT`); - } catch (e) { - if (!/duplicate column/i.test(e.message)) throw e; + // Create pharmacies table — PG when available, SQLite fallback + if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS pharmacies ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + address TEXT NOT NULL, + phone TEXT, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + opening_hours TEXT + ) + `); + } else { + 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, + opening_hours TEXT + ) + `); + // Add opening_hours to pre-existing pharmacies tables (no-op if already present) + try { + await dbRun(`ALTER TABLE pharmacies ADD COLUMN opening_hours TEXT`); + } catch (e) { + if (!/duplicate column/i.test(e.message)) throw e; + } } // 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) - ) - `); - - await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`); + if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS pharmacy_medicines ( + id SERIAL PRIMARY KEY, + pharmacy_id INTEGER NOT NULL REFERENCES pharmacies(id), + medicine_nregistro TEXT NOT NULL, + medicine_name TEXT, + price REAL, + stock INTEGER DEFAULT 0, + UNIQUE(pharmacy_id, medicine_nregistro) + ) + `); + await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`); + } else { + 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) + ) + `); + await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`); + } // Create users table — PG when available, SQLite fallback if (pgPool) { @@ -395,6 +421,36 @@ if (!pgPool) { `); } + // Recent searches table + if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS recent_searches ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + medicine_id TEXT NOT NULL, + name TEXT NOT NULL, + active_ingredient TEXT, + dosage TEXT, + form TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + } else { + await dbRun(` + CREATE TABLE IF NOT EXISTS recent_searches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + medicine_id TEXT NOT NULL, + name TEXT NOT NULL, + active_ingredient TEXT, + dosage TEXT, + form TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `); + } + // User addresses table if (pgPool) { await pgPool.query(` @@ -456,7 +512,7 @@ app.get('/api/medicines/:medicineId/pharmacies', async (req, res) => { try { const nregistro = req.params.medicineId; // Ahora es el nregistro de CIMA - const pharmacies = await dbAll(` + const pharmacies = await userDbAll(` SELECT p.id, p.name, @@ -519,46 +575,61 @@ const requireAdmin = (req, res, next) => { return res.status(401).json({ error: 'Authentication required' }); }; -// ========== RECENT SEARCHES (session-based) ========== +// ========== RECENT SEARCHES (database-based) ========== -const MAX_RECENT = 10; +const MAX_RECENT = 5; // Get recent searches for the logged-in user -app.get('/api/search/recent', requireAuth, (req, res) => { - res.json(req.session.recentSearches || []); +app.get('/api/search/recent', requireAuth, async (req, res) => { + try { + const userId = req.session.userId; + const rows = await userDbAll( + 'SELECT id, medicine_id, name, active_ingredient, dosage, form, created_at FROM recent_searches WHERE user_id = ? ORDER BY created_at DESC LIMIT ?', + [userId, MAX_RECENT] + ); + res.json(rows.map(r => ({ + id: r.medicine_id, + name: r.name, + active_ingredient: r.active_ingredient, + dosage: r.dosage, + form: r.form, + timestamp: r.created_at, + }))); + } catch (error) { + console.error('Error fetching recent searches:', error); + res.status(500).json({ error: 'Internal server error' }); + } }); // Save a medicine to recent searches -app.post('/api/search/recent', requireAuth, (req, res) => { +app.post('/api/search/recent', requireAuth, async (req, res) => { try { const { medicine } = req.body; if (!medicine || !medicine.id) { return res.status(400).json({ error: 'Medicine data required' }); } - if (!req.session.recentSearches) { - req.session.recentSearches = []; - } + const userId = req.session.userId; - // Remove duplicate if exists (by id) - req.session.recentSearches = req.session.recentSearches.filter( - (m) => m.id !== medicine.id + // Remove duplicate if exists (by medicine_id) + await userDbRun( + 'DELETE FROM recent_searches WHERE user_id = ? AND medicine_id = ?', + [userId, medicine.id] ); - // Add to front with timestamp - req.session.recentSearches.unshift({ - id: medicine.id, - name: medicine.name, - active_ingredient: medicine.active_ingredient, - dosage: medicine.dosage, - form: medicine.form, - timestamp: Date.now(), - }); + // Insert new entry + await userDbRun( + 'INSERT INTO recent_searches (user_id, medicine_id, name, active_ingredient, dosage, form) VALUES (?, ?, ?, ?, ?, ?)', + [userId, medicine.id, medicine.name, medicine.active_ingredient || null, medicine.dosage || null, medicine.form || null] + ); - // Keep only last MAX_RECENT - if (req.session.recentSearches.length > MAX_RECENT) { - req.session.recentSearches = req.session.recentSearches.slice(0, MAX_RECENT); - } + // Trim to MAX_RECENT — delete oldest beyond limit + await userDbRun( + `DELETE FROM recent_searches WHERE user_id = ? AND id NOT IN ( + SELECT id FROM recent_searches WHERE user_id = ? ORDER BY created_at DESC LIMIT ? + )`, + [userId, userId, MAX_RECENT] + ); res.json({ ok: true }); } catch (error) { @@ -570,7 +641,7 @@ app.post('/api/search/recent', requireAuth, (req, res) => { // Get all pharmacies (for admin/debugging) app.get('/api/pharmacies', async (req, res) => { try { - const pharmacies = await dbAll(` + const pharmacies = await userDbAll(` SELECT * FROM pharmacies ORDER BY name `); res.json(pharmacies); @@ -1447,7 +1518,7 @@ app.post('/api/admin/pharmacies', requireAdmin, async (req, res) => { } // Check for duplicate (same name and address) - const existing = await dbGet( + const existing = await userDbGet( 'SELECT * FROM pharmacies WHERE name = ? AND address = ?', [name.trim(), address.trim()] ); @@ -1458,7 +1529,7 @@ app.post('/api/admin/pharmacies', requireAdmin, async (req, res) => { const openingHoursValue = serializeOpeningHours(opening_hours); - const result = await dbRun( + const result = await userDbRun( 'INSERT INTO pharmacies (name, address, phone, latitude, longitude, opening_hours) VALUES (?, ?, ?, ?, ?, ?)', [name.trim(), address.trim(), phone ? phone.trim() : null, latitude || null, longitude || null, openingHoursValue] ); @@ -1467,7 +1538,7 @@ app.post('/api/admin/pharmacies', requireAdmin, async (req, res) => { throw new Error('Failed to get lastID from database insert'); } - const newPharmacy = await dbGet( + const newPharmacy = await userDbGet( 'SELECT * FROM pharmacies WHERE id = ?', [result.lastID] ); @@ -1499,12 +1570,12 @@ app.put('/api/admin/pharmacies/:id', requireAdmin, async (req, res) => { const openingHoursValue = serializeOpeningHours(opening_hours); - await dbRun( + await userDbRun( 'UPDATE pharmacies SET name = ?, address = ?, phone = ?, latitude = ?, longitude = ?, opening_hours = ? WHERE id = ?', [name, address, phone || null, latitude || null, longitude || null, openingHoursValue, pharmacyId] ); - const updatedPharmacy = await dbGet( + const updatedPharmacy = await userDbGet( 'SELECT * FROM pharmacies WHERE id = ?', [pharmacyId] ); @@ -1526,10 +1597,10 @@ app.delete('/api/admin/pharmacies/:id', requireAdmin, async (req, res) => { const pharmacyId = parseInt(req.params.id); // Delete related pharmacy_medicines first - await dbRun('DELETE FROM pharmacy_medicines WHERE pharmacy_id = ?', [pharmacyId]); + await userDbRun('DELETE FROM pharmacy_medicines WHERE pharmacy_id = ?', [pharmacyId]); // Delete the pharmacy - await dbRun('DELETE FROM pharmacies WHERE id = ?', [pharmacyId]); + await userDbRun('DELETE FROM pharmacies WHERE id = ?', [pharmacyId]); res.json({ message: 'Pharmacy deleted successfully' }); } catch (error) { @@ -1555,8 +1626,8 @@ app.post('/api/admin/pharmacies/import-webhook', requireAdmin, async (req, res) const hasRegion = region.lat != null || region.lon != null || region.radio != null; const result = await runFarmaciaWebhookImport( - dbGet, - dbRun, + userDbGet, + userDbRun, url.trim(), hasRegion ? region : null ); @@ -1604,7 +1675,7 @@ app.post('/api/admin/pharmacies/import-external', requireAdmin, async (req, res) }); } - const stats = await importPharmaciesFromRows(dbGet, dbRun, rows); + const stats = await importPharmaciesFromRows(userDbGet, userDbRun, rows); res.json({ ...stats, totalReceived: rows.length, source }); } catch (error) { console.error('External pharmacy import:', error); @@ -1636,7 +1707,7 @@ app.get('/api/admin/pharmacies/:pharmacyId/medicines', requireAdmin, async (req, try { const pharmacyId = parseInt(req.params.pharmacyId); - const medicines = await dbAll(` + const medicines = await userDbAll(` SELECT pm.id, pm.pharmacy_id, @@ -1666,33 +1737,33 @@ app.post('/api/admin/pharmacy-medicines', requireAdmin, async (req, res) => { } // Check if relationship already exists - const existing = await dbGet( + const existing = await userDbGet( 'SELECT * FROM pharmacy_medicines WHERE pharmacy_id = ? AND medicine_nregistro = ?', [pharmacy_id, medicine_nregistro] ); if (existing) { // Update existing relationship - await dbRun( + await userDbRun( 'UPDATE pharmacy_medicines SET medicine_name = ?, price = ?, stock = ? WHERE pharmacy_id = ? AND medicine_nregistro = ?', [medicine_name, price || null, stock || 0, pharmacy_id, medicine_nregistro] ); } else { // Insert new relationship - await dbRun( + await userDbRun( 'INSERT INTO pharmacy_medicines (pharmacy_id, medicine_nregistro, medicine_name, price, stock) VALUES (?, ?, ?, ?, ?)', [pharmacy_id, medicine_nregistro, medicine_name, price || null, stock || 0] ); } - const relationship = await dbGet( + const relationship = await userDbGet( `SELECT * FROM pharmacy_medicines WHERE pharmacy_id = ? AND medicine_nregistro = ?`, [pharmacy_id, medicine_nregistro] ); if (!existing) { - const pharmacy = await dbGet('SELECT id, name FROM pharmacies WHERE id = ?', [pharmacy_id]); + const pharmacy = await userDbGet('SELECT id, name FROM pharmacies WHERE id = ?', [pharmacy_id]); sendPushForMedicine({ medicine_nregistro, medicine_name: medicine_name || relationship?.medicine_name || null, @@ -1713,12 +1784,12 @@ app.put('/api/admin/pharmacy-medicines/:id', requireAdmin, async (req, res) => { const id = parseInt(req.params.id); const { price, stock } = req.body; - await dbRun( + await userDbRun( 'UPDATE pharmacy_medicines SET price = ?, stock = ? WHERE id = ?', [price || null, stock || 0, id] ); - const updated = await dbGet( + const updated = await userDbGet( 'SELECT * FROM pharmacy_medicines WHERE id = ?', [id] ); @@ -1738,7 +1809,7 @@ app.put('/api/admin/pharmacy-medicines/:id', requireAdmin, async (req, res) => { app.delete('/api/admin/pharmacy-medicines/:id', requireAdmin, async (req, res) => { try { const id = parseInt(req.params.id); - await dbRun('DELETE FROM pharmacy_medicines WHERE id = ?', [id]); + await userDbRun('DELETE FROM pharmacy_medicines WHERE id = ?', [id]); res.json({ message: 'Medicine removed from pharmacy successfully' }); } catch (error) { console.error('Error deleting pharmacy-medicine:', error); diff --git a/apps/frontend-mobile/app/medicine/[id].tsx b/apps/frontend-mobile/app/medicine/[id].tsx index 84d0aa1..96db6c9 100644 --- a/apps/frontend-mobile/app/medicine/[id].tsx +++ b/apps/frontend-mobile/app/medicine/[id].tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, ScrollView, StyleSheet, TouchableOpacity } from 'react-native'; +import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; import { getMedicine, getMedicinePharmacies } from '../../services/medicines'; import { StockBadge } from '../../components/StockBadge'; import { LoadingSpinner } from '../../components/LoadingSpinner'; @@ -35,6 +36,11 @@ export default function MedicineDetailScreen() { fetchMedicine(); }, [id]); + const handleDirections = (latitude: number, longitude: number) => { + const url = `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`; + Linking.openURL(url); + }; + if (isLoading) { return ; } @@ -74,20 +80,30 @@ export default function MedicineDetailScreen() { No hay farmacias con este medicamento ) : ( pharmacies.map((pharm) => ( - router.push(`/pharmacy/${pharm.pharmacy_id}`)} - > - - {pharm.pharmacy?.name} - {pharm.pharmacy?.address} - - - {pharm.price.toFixed(2)} € - Stock: {pharm.stock} - - + + router.push(`/pharmacy/${pharm.pharmacy_id}`)} + > + + {pharm.pharmacy?.name} + {pharm.pharmacy?.address} + + + {pharm.price.toFixed(2)} € + Stock: {pharm.stock} + + + {pharm.pharmacy?.latitude != null && pharm.pharmacy?.longitude != null && ( + handleDirections(pharm.pharmacy.latitude, pharm.pharmacy.longitude)} + > + + Cómo llegar + + )} + )) )} @@ -160,12 +176,15 @@ const styles = StyleSheet.create({ padding: spacing.xl, }, pharmacyCard: { - flexDirection: 'row', - justifyContent: 'space-between', backgroundColor: colors.card, borderRadius: borderRadius.md, - padding: spacing.md, marginBottom: spacing.sm, + overflow: 'hidden', + }, + pharmacyCardContent: { + flexDirection: 'row', + justifyContent: 'space-between', + padding: spacing.md, }, pharmacyInfo: { flex: 1, @@ -193,6 +212,20 @@ const styles = StyleSheet.create({ color: colors.textSecondary, marginTop: spacing.xs, }, + directionsButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.separator, + }, + directionsText: { + color: colors.primary, + fontSize: 14, + fontWeight: '600', + }, errorContainer: { flex: 1, justifyContent: 'center', diff --git a/apps/frontend/src/App.jsx b/apps/frontend/src/App.jsx index a21df80..5b4d14e 100644 --- a/apps/frontend/src/App.jsx +++ b/apps/frontend/src/App.jsx @@ -125,7 +125,15 @@ function App() { ); break; case 'alerts': - activeView = ; + activeView = ( + { + setPrescriptionSearch(name); + setScreen('search'); + }} + /> + ); break; case 'search': activeView = ( diff --git a/apps/frontend/src/components/PharmacyList.css b/apps/frontend/src/components/PharmacyList.css index 34ec18b..3f4cd9b 100644 --- a/apps/frontend/src/components/PharmacyList.css +++ b/apps/frontend/src/components/PharmacyList.css @@ -159,6 +159,28 @@ margin-top: 1rem; } +.pharmacy-directions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.75rem; + padding: 0.45rem 0.85rem; + font-size: 0.82rem; + font-weight: 600; + color: var(--primary); + background: rgba(0, 69, 13, 0.06); + border: 1px solid rgba(0, 69, 13, 0.15); + border-radius: var(--radius-full); + text-decoration: none; + transition: background 0.15s, border-color 0.15s; + width: fit-content; +} + +.pharmacy-directions:hover { + background: rgba(0, 69, 13, 0.12); + border-color: var(--primary); +} + @media (max-width: 768px) { .pharmacy-grid { grid-template-columns: 1fr; diff --git a/apps/frontend/src/components/PharmacyList.jsx b/apps/frontend/src/components/PharmacyList.jsx index a59e7aa..45a8a06 100644 --- a/apps/frontend/src/components/PharmacyList.jsx +++ b/apps/frontend/src/components/PharmacyList.jsx @@ -45,6 +45,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser medicine={medicine} currentUser={currentUser} onLoginRequest={onLoginRequest} + userPosition={userPosition} /> ); })} @@ -53,7 +54,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser ); } -function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequest }) { +function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequest, userPosition }) { const nregistro = medicine?.nregistro || medicine?.id; const supported = pushSupported(); const outOfStock = pharmacy.stock !== undefined && pharmacy.stock <= 0; @@ -146,6 +147,23 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ

📞 {pharmacy.phone}

)} {error &&

{error}

} + {pharmacy.latitude != null && pharmacy.longitude != null && ( + + + + + Cómo llegar + + )}
{pharmacy.price && ( €{parseFloat(pharmacy.price).toFixed(2)} diff --git a/apps/frontend/src/utils/geo.js b/apps/frontend/src/utils/geo.js index 5acdaaa..b5ff879 100644 --- a/apps/frontend/src/utils/geo.js +++ b/apps/frontend/src/utils/geo.js @@ -10,7 +10,87 @@ export function haversineKm(lat1, lon1, lat2, lon2) { return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } -export function getUserPosition() { +// --- Position Cache (localStorage) --- +const POSITION_CACHE_KEY = 'farmafinder_last_position'; +const POSITION_CACHE_MAX_AGE = 5 * 60 * 1000; // 5 minutes + +function getCachedPosition() { + try { + const raw = localStorage.getItem(POSITION_CACHE_KEY); + if (!raw) return null; + const cached = JSON.parse(raw); + if (Date.now() - cached.timestamp > POSITION_CACHE_MAX_AGE) return null; + return { lat: cached.lat, lon: cached.lon }; + } catch { + return null; + } +} + +function setCachedPosition(lat, lon) { + try { + localStorage.setItem(POSITION_CACHE_KEY, JSON.stringify({ lat, lon, timestamp: Date.now() })); + } catch {} +} + +function clearCachedPosition() { + try { + localStorage.removeItem(POSITION_CACHE_KEY); + } catch {} +} + +// --- Position Manager --- +let pendingPositionRequest = null; + +/** + * Get user position with caching strategy: + * 1. Return cached position if fresh (< 5 min) + * 2. Request browser geolocation in background + * 3. If browser fails, fall back to cached (up to 15 min) + * + * This makes the first request nearly instant for repeat users. + */ +export function getUserPosition({ timeout = 15000, maximumAge = 300000, retries = 1 } = {}) { + // Cancel any pending request before starting a new one + if (pendingPositionRequest) { + pendingPositionRequest.cancel(); + pendingPositionRequest = null; + } + + // 1. Fast path: return fresh cached position immediately + const cached = getCachedPosition(); + if (cached) { + // Also refresh in background (don't await) + refreshPositionInBackground(timeout, maximumAge); + return Promise.resolve(cached); + } + + // 2. Slow path: request from browser + return requestBrowserPosition({ timeout, maximumAge, retries }).then(pos => { + setCachedPosition(pos.lat, pos.lon); + return pos; + }); +} + +/** + * Refresh position silently in background — updates cache for next call. + */ +function refreshPositionInBackground(timeout, maximumAge) { + if (!navigator.geolocation) return; + + navigator.geolocation.getCurrentPosition( + pos => { + const newPos = { lat: pos.coords.latitude, lon: pos.coords.longitude }; + setCachedPosition(newPos.lat, newPos.lon); + }, + () => {}, // silently fail — we already have a cached position + { timeout, maximumAge, enableHighAccuracy: false } + ); +} + +/** + * Core browser geolocation request with retry logic. + */ +function requestBrowserPosition({ timeout = 15000, maximumAge = 300000, retries = 1 } = {}) { return new Promise((resolve, reject) => { if (!navigator.geolocation) { reject(new Error('Geolocalización no compatible con este navegador')); @@ -20,17 +100,82 @@ export function getUserPosition() { reject(new Error('La geolocalización requiere HTTPS (o localhost)')); return; } - navigator.geolocation.getCurrentPosition( - pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }), - err => { - console.error('[geo] getCurrentPosition failed — code:', err && err.code, 'message:', err && err.message); + + let attempts = 0; + let watchId = null; + let settled = false; + + function cleanup() { + if (watchId !== null) { + navigator.geolocation.clearWatch(watchId); + watchId = null; + } + if (pendingPositionRequest?.id === requestId) { + pendingPositionRequest = null; + } + } + + function succeed(pos) { + if (settled) return; + settled = true; + cleanup(); + const newPos = { lat: pos.coords.latitude, lon: pos.coords.longitude }; + setCachedPosition(newPos.lat, newPos.lon); + resolve(newPos); + } + + function fail(err) { + if (settled) return; + attempts++; + if (attempts <= retries && err?.code === 3) { + const delay = Math.min(1000 * Math.pow(2, attempts - 1), 5000); + console.warn(`[geo] timeout, retrying in ${delay}ms (attempt ${attempts}/${retries})`); + setTimeout(() => { + if (!settled) startWatching(); + }, delay); + } else { + settled = true; + cleanup(); + console.error('[geo] getCurrentPosition failed — code:', err?.code, 'message:', err?.message); reject(err); - }, - { timeout: 15000, maximumAge: 60000, enableHighAccuracy: false } - ); + } + } + + function startWatching() { + if (settled) return; + watchId = navigator.geolocation.watchPosition(succeed, fail, { + timeout, + maximumAge, + enableHighAccuracy: false, + }); + setTimeout(() => { + if (!settled && watchId !== null) { + fail({ code: 3, message: 'Timeout' }); + } + }, timeout + 2000); + } + + const requestId = Date.now(); + pendingPositionRequest = { id: requestId, cancel: () => { settled = true; cleanup(); } }; + + startWatching(); }); } +/** + * Clear cached position (e.g. on logout or when user explicitly wants fresh location). + */ +export function clearPositionCache() { + clearCachedPosition(); +} + +/** + * Check if we have any cached position (for UI hints). + */ +export function hasCachedPosition() { + return getCachedPosition() !== null; +} + export function formatDistance(km) { if (km < 1) return `${Math.round(km * 1000)} m`; if (km < 10) return `${km.toFixed(1)} km`; diff --git a/apps/frontend/src/views/AlertsView.jsx b/apps/frontend/src/views/AlertsView.jsx index d1b0162..ebb7d5f 100644 --- a/apps/frontend/src/views/AlertsView.jsx +++ b/apps/frontend/src/views/AlertsView.jsx @@ -14,7 +14,7 @@ const iconMap = { ), }; -function AlertsView({ onNotificationChange }) { +function AlertsView({ onNotificationChange, onNavigateToMedicine }) { const [availability, setAvailability] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -122,7 +122,13 @@ function AlertsView({ onNotificationChange }) { {iconMap[cardIcon]}
-

{alert.medicine_name || alert.medicine_nregistro}

+

onNavigateToMedicine?.(alert.medicine_name || alert.medicine_nregistro)} + > + {alert.medicine_name || alert.medicine_nregistro} +

{alert.scope === 'pharmacy' ? (
diff --git a/apps/frontend/src/views/SearchView.css b/apps/frontend/src/views/SearchView.css index 6f10caa..7c48b9a 100644 --- a/apps/frontend/src/views/SearchView.css +++ b/apps/frontend/src/views/SearchView.css @@ -313,6 +313,27 @@ .location-error { color: var(--error); font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.retry-location-btn { + background: var(--error); + color: white; + border: none; + padding: 0.25rem 0.6rem; + border-radius: var(--radius); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s; +} + +.retry-location-btn:hover { + opacity: 0.85; } .location-source { diff --git a/apps/frontend/src/views/SearchView.jsx b/apps/frontend/src/views/SearchView.jsx index e86159b..4149600 100644 --- a/apps/frontend/src/views/SearchView.jsx +++ b/apps/frontend/src/views/SearchView.jsx @@ -3,7 +3,7 @@ import SearchBar from '../components/SearchBar'; import MedicineResults from '../components/MedicineResults'; import PharmacyList from '../components/PharmacyList'; import PharmacyMap from '../components/PharmacyMap'; -import { haversineKm, getUserPosition } from '../utils/geo'; +import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo'; import './SearchView.css'; const suggestions = [ @@ -26,6 +26,15 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { const [locationError, setLocationError] = useState(''); const [recentSearches, setRecentSearches] = useState([]); + // Precache position on mount — makes first "sort by distance" nearly instant + useEffect(() => { + if (hasCachedPosition()) { + getUserPosition().then(pos => { + setUserPosition(pos); + }).catch(() => {}); + } + }, []); + // Fetch recent searches when user is logged in useEffect(() => { if (!currentUser) { @@ -59,7 +68,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { timestamp: Date.now(), }, ...filtered, - ].slice(0, 10); + ].slice(0, 5); }); }, [currentUser]); @@ -139,6 +148,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { } if (userPosition) { setSortByDistance(true); + setPositionSource('cached'); return; } setLocating(true); @@ -165,9 +175,9 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { } catch (err) { let msg = 'No se pudo obtener tu ubicación'; if (err && typeof err.code === 'number') { - if (err.code === 1) msg = 'Permiso de ubicación denegado'; - else if (err.code === 2) msg = 'Ubicación no disponible'; - else if (err.code === 3) msg = 'La solicitud de ubicación expiró'; + if (err.code === 1) msg = 'Permiso de ubicación denegado. Permite el acceso a la ubicación en tu navegador.'; + else if (err.code === 2) msg = 'Ubicación no disponible. Verifica que el GPS esté activado.'; + else if (err.code === 3) msg = 'La ubicación tardó demasiado. Intenta de nuevo o verifica tu conexión.'; } setLocationError(msg); } finally { @@ -316,8 +326,16 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { {sortByDistance && positionSource === 'profile' && ( Usando tu dirección guardada )} + {sortByDistance && positionSource === 'cached' && ( + Usando ubicación reciente + )} {locationError && ( - {locationError} + + {locationError} + + )}
)}