354 lines
12 KiB
React
354 lines
12 KiB
React
import React, { useState, useEffect, useMemo } from 'react';
|
|
import '../App.css';
|
|
import SearchBar from '../components/SearchBar';
|
|
import MedicineResults from '../components/MedicineResults';
|
|
import ProductResults from '../components/ProductResults';
|
|
import PharmacyList from '../components/PharmacyList';
|
|
import PharmacyMap from '../components/PharmacyMap';
|
|
import HomeView from './HomeView';
|
|
import ScannerView from './ScannerView';
|
|
import { haversineKm, getUserPosition } from '../utils/geo';
|
|
|
|
function PublicView({
|
|
currentUser,
|
|
onLoginRequest,
|
|
screen: screenProp,
|
|
onScreenChange,
|
|
}) {
|
|
// ponytail: uncontrolled by default (own state); controlled when App.jsx passes props
|
|
// so BottomNav can drive the inner screen directly. No-op fallback breaks tests.
|
|
const [internalScreen, setInternalScreen] = useState('home');
|
|
const screen = screenProp ?? internalScreen;
|
|
const setScreen = onScreenChange ?? setInternalScreen;
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [medicines, setMedicines] = useState([]);
|
|
const [products, setProducts] = useState([]);
|
|
const [searchMode, setSearchMode] = useState('all'); // 'all' | 'medicines' | 'products'
|
|
const [selectedMedicine, setSelectedMedicine] = useState(null);
|
|
const [pharmacies, setPharmacies] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [userPosition, setUserPosition] = useState(null);
|
|
const [positionSource, setPositionSource] = useState(null); // 'profile' | 'browser'
|
|
const [sortByDistance, setSortByDistance] = useState(false);
|
|
const [locating, setLocating] = useState(false);
|
|
const [locationError, setLocationError] = useState('');
|
|
|
|
useEffect(() => {
|
|
const searchMedicines = async () => {
|
|
if (searchQuery.trim().length < 2) {
|
|
setMedicines([]);
|
|
setProducts([]);
|
|
setSelectedMedicine(null);
|
|
setPharmacies([]);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/medicines/search?q=${encodeURIComponent(searchQuery)}`);
|
|
const data = await response.json();
|
|
setMedicines(data);
|
|
} catch (error) {
|
|
console.error('Error searching medicines:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
|
|
try {
|
|
const productsResponse = await fetch(`/api/products/search?q=${encodeURIComponent(searchQuery)}`);
|
|
if (productsResponse.ok) {
|
|
const productsData = await productsResponse.json();
|
|
setProducts(productsData.results || []);
|
|
}
|
|
} catch (err) {
|
|
console.error('Product search error:', err);
|
|
}
|
|
};
|
|
|
|
const timeoutId = setTimeout(searchMedicines, 300);
|
|
return () => clearTimeout(timeoutId);
|
|
}, [searchQuery]);
|
|
|
|
useEffect(() => {
|
|
const fetchPharmacies = async () => {
|
|
if (!selectedMedicine) {
|
|
setPharmacies([]);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/medicines/${selectedMedicine.id}/pharmacies`);
|
|
const data = await response.json();
|
|
setPharmacies(data);
|
|
} catch (error) {
|
|
console.error('Error fetching pharmacies:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchPharmacies();
|
|
}, [selectedMedicine]);
|
|
|
|
const savedLat = currentUser?.latitude;
|
|
const savedLon = currentUser?.longitude;
|
|
const hasSavedCoords = savedLat != null && savedLon != null;
|
|
|
|
const handleSortByDistance = async () => {
|
|
if (sortByDistance) {
|
|
setSortByDistance(false);
|
|
return;
|
|
}
|
|
setLocationError('');
|
|
if (hasSavedCoords) {
|
|
setUserPosition({ lat: savedLat, lon: savedLon });
|
|
setPositionSource('profile');
|
|
setSortByDistance(true);
|
|
// Save to search history
|
|
try {
|
|
fetch('/api/search-history', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
address: currentUser?.address,
|
|
latitude: savedLat,
|
|
longitude: savedLon,
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
console.error('Error saving search history:', err);
|
|
}
|
|
return;
|
|
}
|
|
if (userPosition) {
|
|
setSortByDistance(true);
|
|
return;
|
|
}
|
|
setLocating(true);
|
|
try {
|
|
const pos = await getUserPosition();
|
|
setUserPosition(pos);
|
|
setPositionSource('browser');
|
|
setSortByDistance(true);
|
|
// Save to search history
|
|
try {
|
|
fetch('/api/search-history', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
address: 'Ubicación actual',
|
|
latitude: pos.lat,
|
|
longitude: pos.lon,
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
console.error('Error saving search history:', err);
|
|
}
|
|
} catch (err) {
|
|
console.error('[location] error', err);
|
|
let msg;
|
|
if (err && typeof err.code === 'number') {
|
|
if (err.code === 1) msg = 'Permiso de ubicación denegado — actívalo en la configuración del navegador';
|
|
else if (err.code === 2) msg = 'Ubicación no disponible — comprueba los servicios de ubicación/WiFi';
|
|
else if (err.code === 3) msg = 'La solicitud de ubicación expiró — inténtalo de nuevo';
|
|
else msg = `Error de geolocalización (código ${err.code})`;
|
|
} else {
|
|
msg = err && err.message ? err.message : 'No se pudo obtener tu ubicación';
|
|
}
|
|
setLocationError(msg);
|
|
} finally {
|
|
setLocating(false);
|
|
}
|
|
};
|
|
|
|
const displayedPharmacies = useMemo(() => {
|
|
if (!sortByDistance || !userPosition) return pharmacies;
|
|
return [...pharmacies].sort((a, b) => {
|
|
if (a.latitude == null || a.longitude == null) return 1;
|
|
if (b.latitude == null || b.longitude == null) return -1;
|
|
return (
|
|
haversineKm(userPosition.lat, userPosition.lon, a.latitude, a.longitude) -
|
|
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
|
|
);
|
|
});
|
|
}, [pharmacies, sortByDistance, userPosition]);
|
|
|
|
/* ── Scanner → Search handoff ──────────────────────────── */
|
|
function handleScanSelectMedicine(medicineName) {
|
|
setSearchQuery(medicineName);
|
|
setSelectedMedicine(null);
|
|
setPharmacies([]);
|
|
setScreen('search');
|
|
}
|
|
|
|
/* ── Screens ─────────────────────────────────────────────── */
|
|
if (screen === 'home') {
|
|
return (
|
|
<HomeView
|
|
onScanClick={() => setScreen('scan')}
|
|
onSearchClick={() => setScreen('search')}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (screen === 'scan') {
|
|
return (
|
|
<ScannerView
|
|
onClose={() => setScreen('home')}
|
|
onSelectMedicine={handleScanSelectMedicine}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// screen === 'search'
|
|
return (
|
|
<>
|
|
<header className="app-header">
|
|
<button
|
|
className="back-to-home-btn"
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
setSelectedMedicine(null);
|
|
setPharmacies([]);
|
|
setScreen('home');
|
|
}}
|
|
aria-label="Volver al inicio"
|
|
>
|
|
← Inicio
|
|
</button>
|
|
<h1>💊 FarmaClic</h1>
|
|
<p>Encuentra tu medicamento en farmacias cercanas</p>
|
|
</header>
|
|
|
|
<main className="app-main">
|
|
<SearchBar
|
|
value={searchQuery}
|
|
onChange={setSearchQuery}
|
|
placeholder="Buscar un medicamento..."
|
|
/>
|
|
|
|
{loading && <div className="loading">Buscando...</div>}
|
|
|
|
{searchQuery && (
|
|
<div className="flex gap-2 mb-4">
|
|
<button
|
|
onClick={() => setSearchMode('all')}
|
|
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
|
searchMode === 'all'
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Todos
|
|
</button>
|
|
<button
|
|
onClick={() => setSearchMode('medicines')}
|
|
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
|
searchMode === 'medicines'
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Medicamentos
|
|
</button>
|
|
<button
|
|
onClick={() => setSearchMode('products')}
|
|
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
|
searchMode === 'products'
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Parafarmacia
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{searchQuery && !selectedMedicine && (searchMode === 'all' || searchMode === 'medicines') && (
|
|
<MedicineResults
|
|
medicines={medicines}
|
|
onSelect={setSelectedMedicine}
|
|
query={searchQuery}
|
|
currentUser={currentUser}
|
|
onLoginRequest={onLoginRequest}
|
|
/>
|
|
)}
|
|
|
|
{(searchMode === 'all' || searchMode === 'products') && products.length > 0 && (
|
|
<div className="mt-6">
|
|
<h3 className="text-lg font-semibold mb-3">Parafarmacia y Bebé</h3>
|
|
<ProductResults
|
|
products={products}
|
|
onSelect={(product) => {
|
|
window.location.href = `/product/${product.source}/${product.id}`;
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedMedicine && (
|
|
<div className="selected-medicine-section">
|
|
<div className="medicine-info">
|
|
<h2>{selectedMedicine.name}</h2>
|
|
<div className="medicine-details">
|
|
<span><strong>Principio Activo:</strong> {selectedMedicine.active_ingredient}</span>
|
|
<span><strong>Dosis:</strong> {selectedMedicine.dosage}</span>
|
|
<span><strong>Forma:</strong> {selectedMedicine.form}</span>
|
|
</div>
|
|
<button
|
|
className="back-button"
|
|
onClick={() => {
|
|
setSelectedMedicine(null);
|
|
setPharmacies([]);
|
|
}}
|
|
>
|
|
← Volver a búsqueda
|
|
</button>
|
|
</div>
|
|
|
|
{pharmacies.length > 0 && (
|
|
<div className="pharmacy-controls">
|
|
<button
|
|
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
|
|
onClick={handleSortByDistance}
|
|
disabled={locating}
|
|
>
|
|
{locating
|
|
? '📍 Localizando…'
|
|
: sortByDistance
|
|
? '📍 Ordenado por distancia · Reset'
|
|
: hasSavedCoords
|
|
? '📍 Ordenar por ubicación guardada'
|
|
: '📍 Ordenar por distancia'}
|
|
</button>
|
|
{sortByDistance && positionSource === 'profile' && (
|
|
<span className="location-source">Usando tu dirección guardada</span>
|
|
)}
|
|
{locationError && (
|
|
<span className="location-error">{locationError}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<PharmacyMap pharmacies={displayedPharmacies} />
|
|
<PharmacyList
|
|
pharmacies={displayedPharmacies}
|
|
loading={loading}
|
|
userPosition={sortByDistance ? userPosition : null}
|
|
medicine={selectedMedicine}
|
|
currentUser={currentUser}
|
|
onLoginRequest={onLoginRequest}
|
|
/>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default PublicView;
|