Missing files from commit
Run Tests on Branches / Frontend Tests (push) Has been cancelled
Run Tests on Branches / Backend Tests (push) Has been cancelled

This commit is contained in:
Antoni Nuñez Romeu
2026-07-07 17:44:36 +02:00
parent 7eb791f181
commit a58ce306bf
10 changed files with 497 additions and 126 deletions
+41 -12
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/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 * npm run import-farmacias
* FARMACIAS_WEBHOOK_URL=https://... 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 * 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 * 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 sqlite3 from 'sqlite3';
import { promisify } from 'util'; import { promisify } from 'util';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import pg from 'pg';
import { import {
runFarmaciaWebhookImport, runFarmaciaWebhookImport,
DEFAULT_FARMACIAS_WEBHOOK, DEFAULT_FARMACIAS_WEBHOOK,
@@ -24,19 +27,43 @@ import {
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const dbPath = path.join(__dirname, 'database.sqlite'); const PG_URL = process.env.PG_URL;
const db = new sqlite3.Database(dbPath); let pool = null;
let db = null;
function dbRun(sql, params = []) { let dbRun, dbGet;
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) { if (PG_URL) {
if (err) reject(err); pool = new pg.Pool({ connectionString: PG_URL });
else resolve({ lastID: this.lastID, changes: this.changes });
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) { function parseCli(argv) {
const region = {}; const region = {};
@@ -76,6 +103,7 @@ async function main() {
const { url, region } = parseCli(process.argv); const { url, region } = parseCli(process.argv);
console.log('Fetching pharmacies from:', url); console.log('Fetching pharmacies from:', url);
if (region) console.log('Region query:', region); if (region) console.log('Region query:', region);
console.log('Using:', PG_URL ? 'PostgreSQL' : 'SQLite');
try { try {
const result = await runFarmaciaWebhookImport(dbGet, dbRun, url, region); const result = await runFarmaciaWebhookImport(dbGet, dbRun, url, region);
@@ -97,7 +125,8 @@ async function main() {
} }
process.exitCode = 1; process.exitCode = 1;
} finally { } finally {
db.close(); if (pool) await pool.end();
else if (db) db.close();
} }
} }
+149 -78
View File
@@ -175,42 +175,68 @@ function serializeOpeningHours(value) {
// Initialize database tables // Initialize database tables
async function initDatabase() { async function initDatabase() {
try { try {
// Create pharmacies table // Create pharmacies table — PG when available, SQLite fallback
await dbRun(` if (pgPool) {
CREATE TABLE IF NOT EXISTS pharmacies ( await pgPool.query(`
id INTEGER PRIMARY KEY AUTOINCREMENT, CREATE TABLE IF NOT EXISTS pharmacies (
name TEXT NOT NULL, id SERIAL PRIMARY KEY,
address TEXT NOT NULL, name TEXT NOT NULL,
phone TEXT, address TEXT NOT NULL,
latitude REAL, phone TEXT,
longitude REAL, latitude DOUBLE PRECISION,
opening_hours TEXT longitude DOUBLE PRECISION,
) opening_hours TEXT
`); )
`);
// Add opening_hours to pre-existing pharmacies tables (no-op if already present) } else {
try { await dbRun(`
await dbRun(`ALTER TABLE pharmacies ADD COLUMN opening_hours TEXT`); CREATE TABLE IF NOT EXISTS pharmacies (
} catch (e) { id INTEGER PRIMARY KEY AUTOINCREMENT,
if (!/duplicate column/i.test(e.message)) throw e; 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 // Create junction table for pharmacy-medicine relationships
// Ahora usa nregistro (número de registro de CIMA) en lugar de medicine_id local if (pgPool) {
await dbRun(` await pgPool.query(`
CREATE TABLE IF NOT EXISTS pharmacy_medicines ( CREATE TABLE IF NOT EXISTS pharmacy_medicines (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
pharmacy_id INTEGER NOT NULL, pharmacy_id INTEGER NOT NULL REFERENCES pharmacies(id),
medicine_nregistro TEXT NOT NULL, medicine_nregistro TEXT NOT NULL,
medicine_name TEXT, medicine_name TEXT,
price REAL, price REAL,
stock INTEGER DEFAULT 0, stock INTEGER DEFAULT 0,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id), UNIQUE(pharmacy_id, medicine_nregistro)
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 INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`); 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 // Create users table — PG when available, SQLite fallback
if (pgPool) { 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 // User addresses table
if (pgPool) { if (pgPool) {
await pgPool.query(` await pgPool.query(`
@@ -456,7 +512,7 @@ app.get('/api/medicines/:medicineId/pharmacies', async (req, res) => {
try { try {
const nregistro = req.params.medicineId; // Ahora es el nregistro de CIMA const nregistro = req.params.medicineId; // Ahora es el nregistro de CIMA
const pharmacies = await dbAll(` const pharmacies = await userDbAll(`
SELECT SELECT
p.id, p.id,
p.name, p.name,
@@ -519,46 +575,61 @@ const requireAdmin = (req, res, next) => {
return res.status(401).json({ error: 'Authentication required' }); 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 // Get recent searches for the logged-in user
app.get('/api/search/recent', requireAuth, (req, res) => { app.get('/api/search/recent', requireAuth, async (req, res) => {
res.json(req.session.recentSearches || []); 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 // Save a medicine to recent searches
app.post('/api/search/recent', requireAuth, (req, res) => { app.post('/api/search/recent', requireAuth, async (req, res) => {
try { try {
const { medicine } = req.body; const { medicine } = req.body;
if (!medicine || !medicine.id) { if (!medicine || !medicine.id) {
return res.status(400).json({ error: 'Medicine data required' }); return res.status(400).json({ error: 'Medicine data required' });
} }
if (!req.session.recentSearches) { const userId = req.session.userId;
req.session.recentSearches = [];
}
// Remove duplicate if exists (by id) // Remove duplicate if exists (by medicine_id)
req.session.recentSearches = req.session.recentSearches.filter( await userDbRun(
(m) => m.id !== medicine.id 'DELETE FROM recent_searches WHERE user_id = ? AND medicine_id = ?',
[userId, medicine.id]
); );
// Add to front with timestamp // Insert new entry
req.session.recentSearches.unshift({ await userDbRun(
id: medicine.id, 'INSERT INTO recent_searches (user_id, medicine_id, name, active_ingredient, dosage, form) VALUES (?, ?, ?, ?, ?, ?)',
name: medicine.name, [userId, medicine.id, medicine.name, medicine.active_ingredient || null, medicine.dosage || null, medicine.form || null]
active_ingredient: medicine.active_ingredient, );
dosage: medicine.dosage,
form: medicine.form,
timestamp: Date.now(),
});
// Keep only last MAX_RECENT // Trim to MAX_RECENT — delete oldest beyond limit
if (req.session.recentSearches.length > MAX_RECENT) { await userDbRun(
req.session.recentSearches = req.session.recentSearches.slice(0, MAX_RECENT); `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 }); res.json({ ok: true });
} catch (error) { } catch (error) {
@@ -570,7 +641,7 @@ app.post('/api/search/recent', requireAuth, (req, res) => {
// Get all pharmacies (for admin/debugging) // Get all pharmacies (for admin/debugging)
app.get('/api/pharmacies', async (req, res) => { app.get('/api/pharmacies', async (req, res) => {
try { try {
const pharmacies = await dbAll(` const pharmacies = await userDbAll(`
SELECT * FROM pharmacies ORDER BY name SELECT * FROM pharmacies ORDER BY name
`); `);
res.json(pharmacies); res.json(pharmacies);
@@ -1447,7 +1518,7 @@ app.post('/api/admin/pharmacies', requireAdmin, async (req, res) => {
} }
// Check for duplicate (same name and address) // Check for duplicate (same name and address)
const existing = await dbGet( const existing = await userDbGet(
'SELECT * FROM pharmacies WHERE name = ? AND address = ?', 'SELECT * FROM pharmacies WHERE name = ? AND address = ?',
[name.trim(), address.trim()] [name.trim(), address.trim()]
); );
@@ -1458,7 +1529,7 @@ app.post('/api/admin/pharmacies', requireAdmin, async (req, res) => {
const openingHoursValue = serializeOpeningHours(opening_hours); const openingHoursValue = serializeOpeningHours(opening_hours);
const result = await dbRun( const result = await userDbRun(
'INSERT INTO pharmacies (name, address, phone, latitude, longitude, opening_hours) VALUES (?, ?, ?, ?, ?, ?)', 'INSERT INTO pharmacies (name, address, phone, latitude, longitude, opening_hours) VALUES (?, ?, ?, ?, ?, ?)',
[name.trim(), address.trim(), phone ? phone.trim() : null, latitude || null, longitude || null, openingHoursValue] [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'); throw new Error('Failed to get lastID from database insert');
} }
const newPharmacy = await dbGet( const newPharmacy = await userDbGet(
'SELECT * FROM pharmacies WHERE id = ?', 'SELECT * FROM pharmacies WHERE id = ?',
[result.lastID] [result.lastID]
); );
@@ -1499,12 +1570,12 @@ app.put('/api/admin/pharmacies/:id', requireAdmin, async (req, res) => {
const openingHoursValue = serializeOpeningHours(opening_hours); const openingHoursValue = serializeOpeningHours(opening_hours);
await dbRun( await userDbRun(
'UPDATE pharmacies SET name = ?, address = ?, phone = ?, latitude = ?, longitude = ?, opening_hours = ? WHERE id = ?', 'UPDATE pharmacies SET name = ?, address = ?, phone = ?, latitude = ?, longitude = ?, opening_hours = ? WHERE id = ?',
[name, address, phone || null, latitude || null, longitude || null, openingHoursValue, pharmacyId] [name, address, phone || null, latitude || null, longitude || null, openingHoursValue, pharmacyId]
); );
const updatedPharmacy = await dbGet( const updatedPharmacy = await userDbGet(
'SELECT * FROM pharmacies WHERE id = ?', 'SELECT * FROM pharmacies WHERE id = ?',
[pharmacyId] [pharmacyId]
); );
@@ -1526,10 +1597,10 @@ app.delete('/api/admin/pharmacies/:id', requireAdmin, async (req, res) => {
const pharmacyId = parseInt(req.params.id); const pharmacyId = parseInt(req.params.id);
// Delete related pharmacy_medicines first // 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 // 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' }); res.json({ message: 'Pharmacy deleted successfully' });
} catch (error) { } catch (error) {
@@ -1555,8 +1626,8 @@ app.post('/api/admin/pharmacies/import-webhook', requireAdmin, async (req, res)
const hasRegion = const hasRegion =
region.lat != null || region.lon != null || region.radio != null; region.lat != null || region.lon != null || region.radio != null;
const result = await runFarmaciaWebhookImport( const result = await runFarmaciaWebhookImport(
dbGet, userDbGet,
dbRun, userDbRun,
url.trim(), url.trim(),
hasRegion ? region : null 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 }); res.json({ ...stats, totalReceived: rows.length, source });
} catch (error) { } catch (error) {
console.error('External pharmacy import:', error); console.error('External pharmacy import:', error);
@@ -1636,7 +1707,7 @@ app.get('/api/admin/pharmacies/:pharmacyId/medicines', requireAdmin, async (req,
try { try {
const pharmacyId = parseInt(req.params.pharmacyId); const pharmacyId = parseInt(req.params.pharmacyId);
const medicines = await dbAll(` const medicines = await userDbAll(`
SELECT SELECT
pm.id, pm.id,
pm.pharmacy_id, pm.pharmacy_id,
@@ -1666,33 +1737,33 @@ app.post('/api/admin/pharmacy-medicines', requireAdmin, async (req, res) => {
} }
// Check if relationship already exists // Check if relationship already exists
const existing = await dbGet( const existing = await userDbGet(
'SELECT * FROM pharmacy_medicines WHERE pharmacy_id = ? AND medicine_nregistro = ?', 'SELECT * FROM pharmacy_medicines WHERE pharmacy_id = ? AND medicine_nregistro = ?',
[pharmacy_id, medicine_nregistro] [pharmacy_id, medicine_nregistro]
); );
if (existing) { if (existing) {
// Update existing relationship // Update existing relationship
await dbRun( await userDbRun(
'UPDATE pharmacy_medicines SET medicine_name = ?, price = ?, stock = ? WHERE pharmacy_id = ? AND medicine_nregistro = ?', 'UPDATE pharmacy_medicines SET medicine_name = ?, price = ?, stock = ? WHERE pharmacy_id = ? AND medicine_nregistro = ?',
[medicine_name, price || null, stock || 0, pharmacy_id, medicine_nregistro] [medicine_name, price || null, stock || 0, pharmacy_id, medicine_nregistro]
); );
} else { } else {
// Insert new relationship // Insert new relationship
await dbRun( await userDbRun(
'INSERT INTO pharmacy_medicines (pharmacy_id, medicine_nregistro, medicine_name, price, stock) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO pharmacy_medicines (pharmacy_id, medicine_nregistro, medicine_name, price, stock) VALUES (?, ?, ?, ?, ?)',
[pharmacy_id, medicine_nregistro, medicine_name, price || null, stock || 0] [pharmacy_id, medicine_nregistro, medicine_name, price || null, stock || 0]
); );
} }
const relationship = await dbGet( const relationship = await userDbGet(
`SELECT * FROM pharmacy_medicines `SELECT * FROM pharmacy_medicines
WHERE pharmacy_id = ? AND medicine_nregistro = ?`, WHERE pharmacy_id = ? AND medicine_nregistro = ?`,
[pharmacy_id, medicine_nregistro] [pharmacy_id, medicine_nregistro]
); );
if (!existing) { 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({ sendPushForMedicine({
medicine_nregistro, medicine_nregistro,
medicine_name: medicine_name || relationship?.medicine_name || null, 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 id = parseInt(req.params.id);
const { price, stock } = req.body; const { price, stock } = req.body;
await dbRun( await userDbRun(
'UPDATE pharmacy_medicines SET price = ?, stock = ? WHERE id = ?', 'UPDATE pharmacy_medicines SET price = ?, stock = ? WHERE id = ?',
[price || null, stock || 0, id] [price || null, stock || 0, id]
); );
const updated = await dbGet( const updated = await userDbGet(
'SELECT * FROM pharmacy_medicines WHERE id = ?', 'SELECT * FROM pharmacy_medicines WHERE id = ?',
[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) => { app.delete('/api/admin/pharmacy-medicines/:id', requireAdmin, async (req, res) => {
try { try {
const id = parseInt(req.params.id); 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' }); res.json({ message: 'Medicine removed from pharmacy successfully' });
} catch (error) { } catch (error) {
console.error('Error deleting pharmacy-medicine:', error); console.error('Error deleting pharmacy-medicine:', error);
+51 -18
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; 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 { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { getMedicine, getMedicinePharmacies } from '../../services/medicines'; import { getMedicine, getMedicinePharmacies } from '../../services/medicines';
import { StockBadge } from '../../components/StockBadge'; import { StockBadge } from '../../components/StockBadge';
import { LoadingSpinner } from '../../components/LoadingSpinner'; import { LoadingSpinner } from '../../components/LoadingSpinner';
@@ -35,6 +36,11 @@ export default function MedicineDetailScreen() {
fetchMedicine(); fetchMedicine();
}, [id]); }, [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) { if (isLoading) {
return <LoadingSpinner message="Cargando medicamento..." />; return <LoadingSpinner message="Cargando medicamento..." />;
} }
@@ -74,20 +80,30 @@ export default function MedicineDetailScreen() {
<Text style={styles.noPharmacies}>No hay farmacias con este medicamento</Text> <Text style={styles.noPharmacies}>No hay farmacias con este medicamento</Text>
) : ( ) : (
pharmacies.map((pharm) => ( pharmacies.map((pharm) => (
<TouchableOpacity <View key={pharm.id} style={styles.pharmacyCard}>
key={pharm.id} <TouchableOpacity
style={styles.pharmacyCard} style={styles.pharmacyCardContent}
onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id}`)} onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id}`)}
> >
<View style={styles.pharmacyInfo}> <View style={styles.pharmacyInfo}>
<Text style={styles.pharmacyName}>{pharm.pharmacy?.name}</Text> <Text style={styles.pharmacyName}>{pharm.pharmacy?.name}</Text>
<Text style={styles.pharmacyAddress}>{pharm.pharmacy?.address}</Text> <Text style={styles.pharmacyAddress}>{pharm.pharmacy?.address}</Text>
</View> </View>
<View style={styles.pharmacyStock}> <View style={styles.pharmacyStock}>
<Text style={styles.price}>{pharm.price.toFixed(2)} </Text> <Text style={styles.price}>{pharm.price.toFixed(2)} </Text>
<Text style={styles.stock}>Stock: {pharm.stock}</Text> <Text style={styles.stock}>Stock: {pharm.stock}</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{pharm.pharmacy?.latitude != null && pharm.pharmacy?.longitude != null && (
<TouchableOpacity
style={styles.directionsButton}
onPress={() => handleDirections(pharm.pharmacy.latitude, pharm.pharmacy.longitude)}
>
<Ionicons name="navigate" size={16} color={colors.primary} />
<Text style={styles.directionsText}>Cómo llegar</Text>
</TouchableOpacity>
)}
</View>
)) ))
)} )}
</View> </View>
@@ -160,12 +176,15 @@ const styles = StyleSheet.create({
padding: spacing.xl, padding: spacing.xl,
}, },
pharmacyCard: { pharmacyCard: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: colors.card, backgroundColor: colors.card,
borderRadius: borderRadius.md, borderRadius: borderRadius.md,
padding: spacing.md,
marginBottom: spacing.sm, marginBottom: spacing.sm,
overflow: 'hidden',
},
pharmacyCardContent: {
flexDirection: 'row',
justifyContent: 'space-between',
padding: spacing.md,
}, },
pharmacyInfo: { pharmacyInfo: {
flex: 1, flex: 1,
@@ -193,6 +212,20 @@ const styles = StyleSheet.create({
color: colors.textSecondary, color: colors.textSecondary,
marginTop: spacing.xs, 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: { errorContainer: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
+9 -1
View File
@@ -125,7 +125,15 @@ function App() {
); );
break; break;
case 'alerts': case 'alerts':
activeView = <AlertsView onNotificationChange={refreshBadgeCount} />; activeView = (
<AlertsView
onNotificationChange={refreshBadgeCount}
onNavigateToMedicine={(name) => {
setPrescriptionSearch(name);
setScreen('search');
}}
/>
);
break; break;
case 'search': case 'search':
activeView = ( activeView = (
@@ -159,6 +159,28 @@
margin-top: 1rem; 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) { @media (max-width: 768px) {
.pharmacy-grid { .pharmacy-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+19 -1
View File
@@ -45,6 +45,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
medicine={medicine} medicine={medicine}
currentUser={currentUser} currentUser={currentUser}
onLoginRequest={onLoginRequest} 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 nregistro = medicine?.nregistro || medicine?.id;
const supported = pushSupported(); const supported = pushSupported();
const outOfStock = pharmacy.stock !== undefined && pharmacy.stock <= 0; const outOfStock = pharmacy.stock !== undefined && pharmacy.stock <= 0;
@@ -146,6 +147,23 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
<p className="pharmacy-phone">📞 {pharmacy.phone}</p> <p className="pharmacy-phone">📞 {pharmacy.phone}</p>
)} )}
{error && <p className="notify-error">{error}</p>} {error && <p className="notify-error">{error}</p>}
{pharmacy.latitude != null && pharmacy.longitude != null && (
<a
className="pharmacy-directions"
href={
userPosition
? `https://www.google.com/maps/dir/?api=1&origin=${userPosition.lat},${userPosition.lon}&destination=${pharmacy.latitude},${pharmacy.longitude}`
: `https://www.google.com/maps/dir/?api=1&destination=${pharmacy.latitude},${pharmacy.longitude}`
}
target="_blank"
rel="noopener noreferrer"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="3 11 22 2 13 21 11 13 3 11" />
</svg>
Cómo llegar
</a>
)}
<div className="pharmacy-pricing"> <div className="pharmacy-pricing">
{pharmacy.price && ( {pharmacy.price && (
<span className="price">{parseFloat(pharmacy.price).toFixed(2)}</span> <span className="price">{parseFloat(pharmacy.price).toFixed(2)}</span>
+153 -8
View File
@@ -10,7 +10,87 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 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) => { return new Promise((resolve, reject) => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
reject(new Error('Geolocalización no compatible con este navegador')); 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)')); reject(new Error('La geolocalización requiere HTTPS (o localhost)'));
return; return;
} }
navigator.geolocation.getCurrentPosition(
pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }), let attempts = 0;
err => { let watchId = null;
console.error('[geo] getCurrentPosition failed — code:', err && err.code, 'message:', err && err.message); 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); 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) { export function formatDistance(km) {
if (km < 1) return `${Math.round(km * 1000)} m`; if (km < 1) return `${Math.round(km * 1000)} m`;
if (km < 10) return `${km.toFixed(1)} km`; if (km < 10) return `${km.toFixed(1)} km`;
+8 -2
View File
@@ -14,7 +14,7 @@ const iconMap = {
), ),
}; };
function AlertsView({ onNotificationChange }) { function AlertsView({ onNotificationChange, onNavigateToMedicine }) {
const [availability, setAvailability] = useState([]); const [availability, setAvailability] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -122,7 +122,13 @@ function AlertsView({ onNotificationChange }) {
{iconMap[cardIcon]} {iconMap[cardIcon]}
</div> </div>
<div className="alert-info"> <div className="alert-info">
<h3 className="alert-title">{alert.medicine_name || alert.medicine_nregistro}</h3> <h3
className="alert-title"
style={{ cursor: 'pointer', textDecoration: 'underline', color: 'var(--primary, #6750a4)' }}
onClick={() => onNavigateToMedicine?.(alert.medicine_name || alert.medicine_nregistro)}
>
{alert.medicine_name || alert.medicine_nregistro}
</h3>
{alert.scope === 'pharmacy' ? ( {alert.scope === 'pharmacy' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', marginTop: '0.25rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', marginTop: '0.25rem' }}>
<span className={`alert-badge alert-badge--${cardColor}`} style={{ width: 'fit-content' }}> <span className={`alert-badge alert-badge--${cardColor}`} style={{ width: 'fit-content' }}>
+21
View File
@@ -313,6 +313,27 @@
.location-error { .location-error {
color: var(--error); color: var(--error);
font-size: 0.85rem; 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 { .location-source {
+24 -6
View File
@@ -3,7 +3,7 @@ import SearchBar from '../components/SearchBar';
import MedicineResults from '../components/MedicineResults'; import MedicineResults from '../components/MedicineResults';
import PharmacyList from '../components/PharmacyList'; import PharmacyList from '../components/PharmacyList';
import PharmacyMap from '../components/PharmacyMap'; import PharmacyMap from '../components/PharmacyMap';
import { haversineKm, getUserPosition } from '../utils/geo'; import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
import './SearchView.css'; import './SearchView.css';
const suggestions = [ const suggestions = [
@@ -26,6 +26,15 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
const [locationError, setLocationError] = useState(''); const [locationError, setLocationError] = useState('');
const [recentSearches, setRecentSearches] = 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 // Fetch recent searches when user is logged in
useEffect(() => { useEffect(() => {
if (!currentUser) { if (!currentUser) {
@@ -59,7 +68,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
timestamp: Date.now(), timestamp: Date.now(),
}, },
...filtered, ...filtered,
].slice(0, 10); ].slice(0, 5);
}); });
}, [currentUser]); }, [currentUser]);
@@ -139,6 +148,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
} }
if (userPosition) { if (userPosition) {
setSortByDistance(true); setSortByDistance(true);
setPositionSource('cached');
return; return;
} }
setLocating(true); setLocating(true);
@@ -165,9 +175,9 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
} catch (err) { } catch (err) {
let msg = 'No se pudo obtener tu ubicación'; let msg = 'No se pudo obtener tu ubicación';
if (err && typeof err.code === 'number') { if (err && typeof err.code === 'number') {
if (err.code === 1) msg = 'Permiso de ubicación denegado'; 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'; else if (err.code === 2) msg = 'Ubicación no disponible. Verifica que el GPS esté activado.';
else if (err.code === 3) msg = 'La solicitud de ubicación expiró'; else if (err.code === 3) msg = 'La ubicación tardó demasiado. Intenta de nuevo o verifica tu conexión.';
} }
setLocationError(msg); setLocationError(msg);
} finally { } finally {
@@ -316,8 +326,16 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
{sortByDistance && positionSource === 'profile' && ( {sortByDistance && positionSource === 'profile' && (
<span className="location-source">Usando tu dirección guardada</span> <span className="location-source">Usando tu dirección guardada</span>
)} )}
{sortByDistance && positionSource === 'cached' && (
<span className="location-source">Usando ubicación reciente</span>
)}
{locationError && ( {locationError && (
<span className="location-error">{locationError}</span> <span className="location-error">
{locationError}
<button className="retry-location-btn" onClick={handleSortByDistance}>
Reintentar
</button>
</span>
)} )}
</div> </div>
)} )}