de8fdbb904
Run Tests on Branches / Detect Changes (push) Successful in 13s
Run Tests on Branches / Backend Tests (push) Failing after 17s
Run Tests on Branches / Frontend Tests (push) Failing after 16s
Run Tests on Branches / Frontend Mobile Tests (push) Failing after 18s
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
- Added sort by distance functionality (same as SearchView) - Added PharmacyList component with distance display - Added 'Cómo llegar' button in map popup (Google Maps directions) - Uses haversineKm for distance calculation
248 lines
8.1 KiB
React
248 lines
8.1 KiB
React
import React, { useState, useEffect, useMemo } from 'react';
|
|
import PharmacyMap from '../components/PharmacyMap';
|
|
import PharmacyList from '../components/PharmacyList';
|
|
import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
|
|
import './ProductView.css';
|
|
|
|
export default function ProductView({ source, id, onBack }) {
|
|
const [product, setProduct] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [pharmacies, setPharmacies] = useState([]);
|
|
const [loadingPharmacies, setLoadingPharmacies] = useState(false);
|
|
const [sortByDistance, setSortByDistance] = useState(false);
|
|
const [userPosition, setUserPosition] = useState(null);
|
|
const [positionSource, setPositionSource] = useState(null);
|
|
const [locating, setLocating] = useState(false);
|
|
const [locationError, setLocationError] = useState('');
|
|
|
|
const hasSavedCoords = product?.latitude != null && product?.longitude != null;
|
|
|
|
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]);
|
|
|
|
const handleSortByDistance = async () => {
|
|
if (sortByDistance) {
|
|
setSortByDistance(false);
|
|
return;
|
|
}
|
|
setLocationError('');
|
|
setLocating(true);
|
|
try {
|
|
const pos = await getUserPosition();
|
|
setUserPosition(pos);
|
|
setPositionSource('browser');
|
|
setSortByDistance(true);
|
|
} 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 ubicación tardó demasiado.';
|
|
}
|
|
setLocationError(msg);
|
|
} finally {
|
|
setLocating(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadProduct();
|
|
}, [source, id]);
|
|
|
|
async function loadProduct() {
|
|
setLoading(true);
|
|
setError(null);
|
|
setPharmacies([]);
|
|
try {
|
|
// Use parapharmacy endpoint for all non-CIMA sources
|
|
const isCima = source === 'cima';
|
|
const apiUrl = isCima
|
|
? `/api/products/${source}/${id}`
|
|
: `/api/products/parapharmacy/${id}`;
|
|
|
|
const response = await fetch(apiUrl);
|
|
if (!response.ok) {
|
|
throw new Error('Producto no encontrado');
|
|
}
|
|
const data = await response.json();
|
|
setProduct(data);
|
|
loadPharmacies(source, data.id || data._id);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadPharmacies(productSource, productId) {
|
|
setLoadingPharmacies(true);
|
|
try {
|
|
const response = await fetch(`/api/products/${productSource}/${productId}/pharmacies`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setPharmacies(data);
|
|
}
|
|
} catch {
|
|
// Pharmacies are optional — don't block on failure
|
|
} finally {
|
|
setLoadingPharmacies(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="product-view">
|
|
<div className="product-loading">Cargando...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="product-view">
|
|
<button className="back-btn" onClick={onBack}>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
</svg>
|
|
Volver
|
|
</button>
|
|
<div className="product-error">{error}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!product) return null;
|
|
|
|
const isCima = product.source === 'cima';
|
|
const isParapharmacy = product.source !== 'cima';
|
|
const sourceColor = isCima ? '#2563eb' : '#16a34a';
|
|
const sourceLabel = isCima ? 'CIMA' : 'Parafarmacia';
|
|
|
|
return (
|
|
<div className="product-view">
|
|
<button className="back-btn" onClick={onBack}>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
</svg>
|
|
Volver
|
|
</button>
|
|
|
|
<div className="product-header">
|
|
{product.image_url && (
|
|
<img src={product.image_url} alt={product.name} className="product-image" />
|
|
)}
|
|
<span className="source-badge" style={{ backgroundColor: sourceColor }}>
|
|
{sourceLabel}
|
|
</span>
|
|
</div>
|
|
|
|
<h1 className="product-name">{product.name}</h1>
|
|
<p className="product-brand">{product.brand}</p>
|
|
|
|
<div className="product-details">
|
|
{isCima ? (
|
|
<>
|
|
{product.active_ingredient && (
|
|
<DetailRow label="Principio activo" value={product.active_ingredient} />
|
|
)}
|
|
{product.dosage && (
|
|
<DetailRow label="Dosis" value={product.dosage} />
|
|
)}
|
|
{product.form && (
|
|
<DetailRow label="Forma farmacéutica" value={product.form} />
|
|
)}
|
|
{product.prescription && (
|
|
<DetailRow label="Prescripción" value={product.prescription} />
|
|
)}
|
|
{product.commercialized !== undefined && (
|
|
<DetailRow label="Comercializado" value={product.commercialized ? 'Sí' : 'No'} />
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{product.price && (
|
|
<DetailRow label="Precio" value={`${product.price} €`} />
|
|
)}
|
|
{product.original_price && product.original_price > product.price && (
|
|
<DetailRow label="Precio anterior" value={`${product.original_price} €`} />
|
|
)}
|
|
{product.category && (
|
|
<DetailRow label="Categoría" value={product.category} />
|
|
)}
|
|
{product.brand && (
|
|
<DetailRow label="Marca" value={product.brand} />
|
|
)}
|
|
{product.source_url && (
|
|
<DetailRow label="Fuente" value={product.source} />
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="product-pharmacies">
|
|
{loadingPharmacies ? (
|
|
<div className="pharmacies-loading">Cargando farmacias...</div>
|
|
) : pharmacies.length > 0 ? (
|
|
<>
|
|
<h3 className="pharmacies-title">
|
|
Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'}
|
|
</h3>
|
|
|
|
<div className="pharmacy-controls">
|
|
<button
|
|
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
|
|
onClick={handleSortByDistance}
|
|
disabled={locating}
|
|
>
|
|
{locating
|
|
? '📍 Localizando…'
|
|
: sortByDistance
|
|
? '📍 Ordenado por distancia · Reset'
|
|
: '📍 Ordenar por distancia'}
|
|
</button>
|
|
{sortByDistance && positionSource && (
|
|
<span className="location-source">Usando tu ubicación</span>
|
|
)}
|
|
{locationError && (
|
|
<span className="location-error">
|
|
{locationError}
|
|
<button className="retry-location-btn" onClick={handleSortByDistance}>
|
|
Reintentar
|
|
</button>
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<PharmacyMap pharmacies={displayedPharmacies} />
|
|
|
|
<PharmacyList
|
|
pharmacies={displayedPharmacies}
|
|
loading={loadingPharmacies}
|
|
userPosition={sortByDistance ? userPosition : null}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DetailRow({ label, value }) {
|
|
return (
|
|
<div className="detail-row">
|
|
<span className="detail-label">{label}</span>
|
|
<span className="detail-value">{value}</span>
|
|
</div>
|
|
);
|
|
}
|