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
+9 -1
View File
@@ -125,7 +125,15 @@ function App() {
);
break;
case 'alerts':
activeView = <AlertsView onNotificationChange={refreshBadgeCount} />;
activeView = (
<AlertsView
onNotificationChange={refreshBadgeCount}
onNavigateToMedicine={(name) => {
setPrescriptionSearch(name);
setScreen('search');
}}
/>
);
break;
case 'search':
activeView = (
@@ -159,6 +159,28 @@
margin-top: 1rem;
}
.pharmacy-directions {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
padding: 0.45rem 0.85rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--primary);
background: rgba(0, 69, 13, 0.06);
border: 1px solid rgba(0, 69, 13, 0.15);
border-radius: var(--radius-full);
text-decoration: none;
transition: background 0.15s, border-color 0.15s;
width: fit-content;
}
.pharmacy-directions:hover {
background: rgba(0, 69, 13, 0.12);
border-color: var(--primary);
}
@media (max-width: 768px) {
.pharmacy-grid {
grid-template-columns: 1fr;
+19 -1
View File
@@ -45,6 +45,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
medicine={medicine}
currentUser={currentUser}
onLoginRequest={onLoginRequest}
userPosition={userPosition}
/>
);
})}
@@ -53,7 +54,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
);
}
function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequest }) {
function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequest, userPosition }) {
const nregistro = medicine?.nregistro || medicine?.id;
const supported = pushSupported();
const outOfStock = pharmacy.stock !== undefined && pharmacy.stock <= 0;
@@ -146,6 +147,23 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
<p className="pharmacy-phone">📞 {pharmacy.phone}</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">
{pharmacy.price && (
<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));
}
export function getUserPosition() {
// --- Position Cache (localStorage) ---
const POSITION_CACHE_KEY = 'farmafinder_last_position';
const POSITION_CACHE_MAX_AGE = 5 * 60 * 1000; // 5 minutes
function getCachedPosition() {
try {
const raw = localStorage.getItem(POSITION_CACHE_KEY);
if (!raw) return null;
const cached = JSON.parse(raw);
if (Date.now() - cached.timestamp > POSITION_CACHE_MAX_AGE) return null;
return { lat: cached.lat, lon: cached.lon };
} catch {
return null;
}
}
function setCachedPosition(lat, lon) {
try {
localStorage.setItem(POSITION_CACHE_KEY, JSON.stringify({ lat, lon, timestamp: Date.now() }));
} catch {}
}
function clearCachedPosition() {
try {
localStorage.removeItem(POSITION_CACHE_KEY);
} catch {}
}
// --- Position Manager ---
let pendingPositionRequest = null;
/**
* Get user position with caching strategy:
* 1. Return cached position if fresh (< 5 min)
* 2. Request browser geolocation in background
* 3. If browser fails, fall back to cached (up to 15 min)
*
* This makes the first request nearly instant for repeat users.
*/
export function getUserPosition({ timeout = 15000, maximumAge = 300000, retries = 1 } = {}) {
// Cancel any pending request before starting a new one
if (pendingPositionRequest) {
pendingPositionRequest.cancel();
pendingPositionRequest = null;
}
// 1. Fast path: return fresh cached position immediately
const cached = getCachedPosition();
if (cached) {
// Also refresh in background (don't await)
refreshPositionInBackground(timeout, maximumAge);
return Promise.resolve(cached);
}
// 2. Slow path: request from browser
return requestBrowserPosition({ timeout, maximumAge, retries }).then(pos => {
setCachedPosition(pos.lat, pos.lon);
return pos;
});
}
/**
* Refresh position silently in background — updates cache for next call.
*/
function refreshPositionInBackground(timeout, maximumAge) {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
pos => {
const newPos = { lat: pos.coords.latitude, lon: pos.coords.longitude };
setCachedPosition(newPos.lat, newPos.lon);
},
() => {}, // silently fail — we already have a cached position
{ timeout, maximumAge, enableHighAccuracy: false }
);
}
/**
* Core browser geolocation request with retry logic.
*/
function requestBrowserPosition({ timeout = 15000, maximumAge = 300000, retries = 1 } = {}) {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocalización no compatible con este navegador'));
@@ -20,17 +100,82 @@ export function getUserPosition() {
reject(new Error('La geolocalización requiere HTTPS (o localhost)'));
return;
}
navigator.geolocation.getCurrentPosition(
pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
err => {
console.error('[geo] getCurrentPosition failed — code:', err && err.code, 'message:', err && err.message);
let attempts = 0;
let watchId = null;
let settled = false;
function cleanup() {
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId);
watchId = null;
}
if (pendingPositionRequest?.id === requestId) {
pendingPositionRequest = null;
}
}
function succeed(pos) {
if (settled) return;
settled = true;
cleanup();
const newPos = { lat: pos.coords.latitude, lon: pos.coords.longitude };
setCachedPosition(newPos.lat, newPos.lon);
resolve(newPos);
}
function fail(err) {
if (settled) return;
attempts++;
if (attempts <= retries && err?.code === 3) {
const delay = Math.min(1000 * Math.pow(2, attempts - 1), 5000);
console.warn(`[geo] timeout, retrying in ${delay}ms (attempt ${attempts}/${retries})`);
setTimeout(() => {
if (!settled) startWatching();
}, delay);
} else {
settled = true;
cleanup();
console.error('[geo] getCurrentPosition failed — code:', err?.code, 'message:', err?.message);
reject(err);
},
{ timeout: 15000, maximumAge: 60000, enableHighAccuracy: false }
);
}
}
function startWatching() {
if (settled) return;
watchId = navigator.geolocation.watchPosition(succeed, fail, {
timeout,
maximumAge,
enableHighAccuracy: false,
});
setTimeout(() => {
if (!settled && watchId !== null) {
fail({ code: 3, message: 'Timeout' });
}
}, timeout + 2000);
}
const requestId = Date.now();
pendingPositionRequest = { id: requestId, cancel: () => { settled = true; cleanup(); } };
startWatching();
});
}
/**
* Clear cached position (e.g. on logout or when user explicitly wants fresh location).
*/
export function clearPositionCache() {
clearCachedPosition();
}
/**
* Check if we have any cached position (for UI hints).
*/
export function hasCachedPosition() {
return getCachedPosition() !== null;
}
export function formatDistance(km) {
if (km < 1) return `${Math.round(km * 1000)} m`;
if (km < 10) return `${km.toFixed(1)} km`;
+8 -2
View File
@@ -14,7 +14,7 @@ const iconMap = {
),
};
function AlertsView({ onNotificationChange }) {
function AlertsView({ onNotificationChange, onNavigateToMedicine }) {
const [availability, setAvailability] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -122,7 +122,13 @@ function AlertsView({ onNotificationChange }) {
{iconMap[cardIcon]}
</div>
<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' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', marginTop: '0.25rem' }}>
<span className={`alert-badge alert-badge--${cardColor}`} style={{ width: 'fit-content' }}>
+21
View File
@@ -313,6 +313,27 @@
.location-error {
color: var(--error);
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.retry-location-btn {
background: var(--error);
color: white;
border: none;
padding: 0.25rem 0.6rem;
border-radius: var(--radius);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s;
}
.retry-location-btn:hover {
opacity: 0.85;
}
.location-source {
+24 -6
View File
@@ -3,7 +3,7 @@ import SearchBar from '../components/SearchBar';
import MedicineResults from '../components/MedicineResults';
import PharmacyList from '../components/PharmacyList';
import PharmacyMap from '../components/PharmacyMap';
import { haversineKm, getUserPosition } from '../utils/geo';
import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
import './SearchView.css';
const suggestions = [
@@ -26,6 +26,15 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
const [locationError, setLocationError] = useState('');
const [recentSearches, setRecentSearches] = useState([]);
// Precache position on mount — makes first "sort by distance" nearly instant
useEffect(() => {
if (hasCachedPosition()) {
getUserPosition().then(pos => {
setUserPosition(pos);
}).catch(() => {});
}
}, []);
// Fetch recent searches when user is logged in
useEffect(() => {
if (!currentUser) {
@@ -59,7 +68,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
timestamp: Date.now(),
},
...filtered,
].slice(0, 10);
].slice(0, 5);
});
}, [currentUser]);
@@ -139,6 +148,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
}
if (userPosition) {
setSortByDistance(true);
setPositionSource('cached');
return;
}
setLocating(true);
@@ -165,9 +175,9 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
} catch (err) {
let msg = 'No se pudo obtener tu ubicación';
if (err && typeof err.code === 'number') {
if (err.code === 1) msg = 'Permiso de ubicación denegado';
else if (err.code === 2) msg = 'Ubicación no disponible';
else if (err.code === 3) msg = 'La solicitud de ubicación expiró';
if (err.code === 1) msg = 'Permiso de ubicación denegado. Permite el acceso a la ubicación en tu navegador.';
else if (err.code === 2) msg = 'Ubicación no disponible. Verifica que el GPS esté activado.';
else if (err.code === 3) msg = 'La ubicación tardó demasiado. Intenta de nuevo o verifica tu conexión.';
}
setLocationError(msg);
} finally {
@@ -316,8 +326,16 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
{sortByDistance && positionSource === 'profile' && (
<span className="location-source">Usando tu dirección guardada</span>
)}
{sortByDistance && positionSource === 'cached' && (
<span className="location-source">Usando ubicación reciente</span>
)}
{locationError && (
<span className="location-error">{locationError}</span>
<span className="location-error">
{locationError}
<button className="retry-location-btn" onClick={handleSortByDistance}>
Reintentar
</button>
</span>
)}
</div>
)}