chore/update-app-icons #17

Merged
Ichitux merged 2 commits from chore/update-app-icons into main 2026-07-07 15:45:33 +00:00
19 changed files with 497 additions and 126 deletions
+37 -8
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) => {
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) { db.run(sql, params, function (err) {
if (err) reject(err); if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes }); 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();
} }
} }
+120 -49
View File
@@ -175,7 +175,20 @@ 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
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(` await dbRun(`
CREATE TABLE IF NOT EXISTS pharmacies ( CREATE TABLE IF NOT EXISTS pharmacies (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -187,16 +200,29 @@ async function initDatabase() {
opening_hours TEXT opening_hours TEXT
) )
`); `);
// Add opening_hours to pre-existing pharmacies tables (no-op if already present) // Add opening_hours to pre-existing pharmacies tables (no-op if already present)
try { try {
await dbRun(`ALTER TABLE pharmacies ADD COLUMN opening_hours TEXT`); await dbRun(`ALTER TABLE pharmacies ADD COLUMN opening_hours TEXT`);
} catch (e) { } catch (e) {
if (!/duplicate column/i.test(e.message)) throw 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 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(` await dbRun(`
CREATE TABLE IF NOT EXISTS pharmacy_medicines ( CREATE TABLE IF NOT EXISTS pharmacy_medicines (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -209,8 +235,8 @@ async function initDatabase() {
UNIQUE(pharmacy_id, medicine_nregistro) UNIQUE(pharmacy_id, medicine_nregistro)
) )
`); `);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(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);
+39 -6
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,9 +80,9 @@ 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) => (
<View key={pharm.id} style={styles.pharmacyCard}>
<TouchableOpacity <TouchableOpacity
key={pharm.id} style={styles.pharmacyCardContent}
style={styles.pharmacyCard}
onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id}`)} onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id}`)}
> >
<View style={styles.pharmacyInfo}> <View style={styles.pharmacyInfo}>
@@ -88,6 +94,16 @@ export default function MedicineDetailScreen() {
<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',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+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,15 +100,80 @@ 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) {
+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>
)} )}