chore/update-app-icons #17
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 33 KiB |
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||